mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-04 05:53:27 +00:00
Compare commits
256 Commits
v1.2.6
...
ci/docker_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab6fc244e | ||
|
|
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 |
23
.github/workflows/app_build.yml
vendored
Normal file
23
.github/workflows/app_build.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Build on Merge
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
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
|
||||
24
.github/workflows/code_quality.yml
vendored
Normal file
24
.github/workflows/code_quality.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Code quality
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
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 .
|
||||
30
.github/workflows/docker.yml
vendored
30
.github/workflows/docker.yml
vendored
@@ -8,6 +8,13 @@ on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
description: Push images to registry
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -26,23 +33,24 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
# Using PAT as a hack due to issues with GITHUB_TOKEN and package permissions
|
||||
# This should be reverted to use GITHUB_TOKEN once a solution is discovered.
|
||||
password: ${{ secrets.GHCR_PAT }}
|
||||
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_owner }}/patchmon-${{ matrix.image }}
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ matrix.image }}
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
@@ -56,7 +64,11 @@ jobs:
|
||||
context: .
|
||||
file: docker/${{ matrix.image }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
# 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 }}
|
||||
|
||||
11
.gitignore
vendored
11
.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
|
||||
@@ -145,4 +154,4 @@ setup-installer-site.sh
|
||||
install-server.*
|
||||
notify-clients-upgrade.sh
|
||||
debug-agent.sh
|
||||
docker/agents
|
||||
docker/compose_dev_data
|
||||
|
||||
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>.
|
||||
275
README.md
275
README.md
@@ -1,127 +1,59 @@
|
||||
# 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
|
||||
|
||||
### Users & Authentication
|
||||
- Multi-user accounts (admin and standard users)
|
||||
- Email/username-based login
|
||||
- Optional Two‑Factor Authentication (TFA/MFA) with verification flow
|
||||
- First‑time admin bootstrap flow (no default credentials; secure setup)
|
||||
- Self‑registration toggle in settings (enable/disable public signup)
|
||||
|
||||
### Roles, Permissions & RBAC
|
||||
- Built‑in roles: `admin`, `user`
|
||||
- Fine‑grained permission flags (e.g., view/manage hosts, packages, users, reports, settings)
|
||||
- Server‑side enforcement for protected routes and UI guards per permission
|
||||
|
||||
### Dashboard
|
||||
- Customisable dashboard with per‑user card layout and ordering
|
||||
- Role/permission‑aware defaults on first login
|
||||
- “Reset to Defaults” uses consistent server‑provided defaults
|
||||
- Cards include: Total Hosts, Needs Updating, Up‑to‑Date Hosts, Host Groups, Outdated Packages, Security Updates, Package Priority, Repositories, Users, OS Distribution (pie/bar), Update Status, Recent Collection, Recent Users, Quick Stats
|
||||
|
||||
### Users & Authentication
|
||||
- Multi-user accounts (admin and standard users)
|
||||
- Roles, Permissions & RBAC
|
||||
|
||||
### Hosts & Inventory
|
||||
- Host inventory with key attributes and OS details
|
||||
- Host inventory/groups with key attributes and OS details
|
||||
- Host grouping (create and manage host groups)
|
||||
- OS distribution summaries and visualisations
|
||||
- Recent telemetry collection indicator
|
||||
|
||||
### Packages & Updates
|
||||
- Package inventory across hosts
|
||||
- Outdated packages overview and counts
|
||||
- Security updates highlight
|
||||
- Update status breakdown (up‑to‑date vs needs updates)
|
||||
|
||||
### Repositories
|
||||
- Repositories per host tracking
|
||||
- Repository module pages and totals
|
||||
|
||||
### Agent & Data Collection
|
||||
- Outbound‑only agent communication (no inbound ports required on hosts)
|
||||
- Agent version management and script content stored in DB
|
||||
- Version marking (current/default) with update history
|
||||
|
||||
### Settings & Configuration
|
||||
- Server URL/protocol/host/port
|
||||
- Update interval and auto‑update toggle
|
||||
- Public signup toggle and default user role selection
|
||||
- Repository settings: GitHub repo URL, repository type, SSH key path
|
||||
- Rate‑limit windows and thresholds for API/auth/agent
|
||||
|
||||
### Admin & User Management
|
||||
- Admin user CRUD (create, list, update, delete)
|
||||
- Password reset (admin‑initiated)
|
||||
- Role assignment on user create/update
|
||||
|
||||
### Reporting & Analytics
|
||||
- Dashboard stats and card‑level metrics
|
||||
- OS distribution charts (pie/bar)
|
||||
- Update status and recent activity summaries
|
||||
- Signup toggle and default user role selection
|
||||
|
||||
### API & Integrations
|
||||
- REST API under `/api/v1` with JWT auth
|
||||
- Consistent JSON responses; errors with appropriate status codes
|
||||
- CORS configured per server settings
|
||||
- **Proxmox LXC Auto-Enrollment** - Automatically discover and enroll LXC containers from Proxmox hosts ([Documentation](PROXMOX_AUTO_ENROLLMENT.md))
|
||||
|
||||
### Security
|
||||
- JWT‑secured API with short, scoped tokens
|
||||
- Permissions enforced server‑side on every route
|
||||
- Rate limiting for general, auth, and agent endpoints
|
||||
- Outbound‑only agent model reduces attack surface
|
||||
|
||||
### Deployment & Operations
|
||||
- One‑line self‑host installer (Ubuntu/Debian)
|
||||
- Automated provisioning: Node.js, PostgreSQL, nginx
|
||||
- Prisma migrations and client generation
|
||||
- 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
|
||||
- Consolidated deployment info file with commands and paths
|
||||
|
||||
### UX & Frontend
|
||||
- Vite + React single‑page app
|
||||
- Protected routes with permission checks
|
||||
- Theming and modern components (icons, modals, notifications)
|
||||
|
||||
### Observability & Logging
|
||||
- Structured server logs
|
||||
- Deployment logs copied to instance dir for later review
|
||||
|
||||
### Road‑Readiness
|
||||
- Works for internal (HTTP) and public (HTTPS) deployments
|
||||
- Defaults safe for first‑time setup; admin created interactively
|
||||
|
||||
## Communication Model
|
||||
|
||||
- Outbound-only agents: servers initiate communication to PatchMon
|
||||
- No inbound connections required on monitored servers
|
||||
- Secure server-side API with JWT authentication and rate limiting
|
||||
|
||||
## Architecture
|
||||
|
||||
- Backend: Node.js/Express + Prisma + PostgreSQL
|
||||
- Frontend: Vite + React
|
||||
- Reverse proxy: nginx
|
||||
- Database: PostgreSQL
|
||||
- System service: systemd-managed backend
|
||||
|
||||
```
|
||||
+----------------------+ HTTPS +--------------------+ HTTP +------------------------+ TCP +---------------+
|
||||
| End Users (Browser) | ---------> | nginx | --------> | Backend (Node/Express) | ------> | PostgreSQL |
|
||||
| Admin UI / Frontend | | serve FE, proxy API| | /api, auth, Prisma | | Database |
|
||||
+----------------------+ +--------------------+ +------------------------+ +---------------+
|
||||
|
||||
Agents (Outbound Only)
|
||||
+---------------------------+ HTTPS +------------------------+
|
||||
| Agents on your servers | ----------> | Backend API (/api/v1) |
|
||||
+---------------------------+ +------------------------+
|
||||
|
||||
Operational
|
||||
- systemd manages backend service
|
||||
- certbot/nginx for TLS (public)
|
||||
- setup.sh bootstraps OS, app, DB, config
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -131,12 +63,38 @@ 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
|
||||
curl -fsSL -o setup.sh https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/setup.sh && chmod +x && bash setup.sh
|
||||
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
|
||||
@@ -155,49 +113,46 @@ After installation:
|
||||
- Visit `http(s)://<your-domain>` and complete first-time admin setup
|
||||
- See all useful info in `deployment-info.txt`
|
||||
|
||||
## 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://discord.gg/S7RXUHwg
|
||||
- Discord: [https://patchmon.net/discord](https://patchmon.net/discord)
|
||||
- Email: support@patchmon.net
|
||||
|
||||
## Roadmap
|
||||
|
||||
- PatchMon Cloud (managed offering)
|
||||
- Additional dashboards and reporting widgets
|
||||
- More OS distributions and agent enhancements
|
||||
- Advanced workflow automations and approvals
|
||||
- Roadmap board: https://github.com/orgs/PatchMon/projects/2
|
||||
|
||||
Roadmap board: https://github.com/users/9technologygroup/projects/1
|
||||
|
||||
## Security
|
||||
|
||||
- Outbound-only agent communications; no inbound ports on monitored hosts
|
||||
- JWT-based API auth, rate limiting, role/permission checks
|
||||
- Follow least-privilege defaults; sensitive operations audited
|
||||
|
||||
## Support Methods
|
||||
|
||||
- Community: Discord for quick questions and feedback
|
||||
- Email: SLA-backed assistance for incidents and issues
|
||||
- GitHub Issues: bug reports and feature requests
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3 (More information on this soon)
|
||||
|
||||
## Links
|
||||
|
||||
- Repository: https://github.com/9technologygroup/patchmon.net/
|
||||
- Raw installer: https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/setup.sh
|
||||
---
|
||||
|
||||
|
||||
# PatchMon
|
||||
|
||||
[](https://discord.gg/S7RXUHwg)
|
||||
[](https://github.com/9technologygroup/patchmon.net)
|
||||
[](https://github.com/users/9technologygroup/projects/1)
|
||||
|
||||
- AGPLv3 (More information on this soon)
|
||||
|
||||
|
||||
---
|
||||
@@ -221,14 +176,19 @@ We welcome contributions from the community! Here's how you can get involved:
|
||||
git checkout -b fix/your-bug-fix
|
||||
```
|
||||
|
||||
4. **Install Dependencies and Setup Hooks**
|
||||
```bash
|
||||
npm install
|
||||
npm run prepare
|
||||
```
|
||||
|
||||
4. **Make Your Changes**
|
||||
5. **Make Your Changes**
|
||||
- Write clean, well-documented code
|
||||
- Follow existing code style and patterns
|
||||
- Add tests for new functionality
|
||||
- Update documentation as needed
|
||||
|
||||
5. **Test Your Changes**
|
||||
6. **Test Your Changes**
|
||||
```bash
|
||||
# Run backend tests
|
||||
cd backend
|
||||
@@ -239,56 +199,28 @@ We welcome contributions from the community! Here's how you can get involved:
|
||||
npm test
|
||||
```
|
||||
|
||||
6. **Commit and Push**
|
||||
7. **Commit and Push**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add: descriptive commit message"
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
7. **Create a Pull Request**
|
||||
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 ESLint configuration
|
||||
- **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
|
||||
|
||||
### Areas We Need Help With
|
||||
- 🐳 **Docker & Containerization** (led by @Adam20054)
|
||||
- 🔄 **CI/CD Pipelines** (led by @tigattack)
|
||||
- 🔒 **Security Improvements** - Security audits, vulnerability assessments, and security feature enhancements
|
||||
- ⚡ **Performance for Large Scale Deployments** - Database optimization, caching strategies, and horizontal scaling
|
||||
- 📚 **Documentation** improvements
|
||||
- 🧪 **Testing** coverage
|
||||
- 🌐 **Internationalization** (i18n)
|
||||
- 📱 **Mobile** responsive improvements
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
Check out our [public roadmap](https://github.com/users/9technologygroup/projects/1) to see what we're working on and what's coming next!
|
||||
|
||||
**Upcoming Features:**
|
||||
- 🐳 Docker Compose deployment
|
||||
- 🔄 Automated CI/CD pipelines
|
||||
- 📊 Advanced reporting and analytics
|
||||
- 🔔 Enhanced notification system
|
||||
- 📱 Mobile application
|
||||
- 🔄 Patch management workflows and policies
|
||||
- 👥 Users inventory management
|
||||
- 🔍 Services and ports monitoring
|
||||
- 🖥️ Proxmox integration for auto LXC discovery and registration
|
||||
- 📧 Notifications via Slack/Email
|
||||
|
||||
---
|
||||
|
||||
## 🏢 Enterprise & Custom Solutions
|
||||
|
||||
@@ -316,22 +248,6 @@ Check out our [public roadmap](https://github.com/users/9technologygroup/project
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Community
|
||||
|
||||
### Get Help
|
||||
- 💬 **Discord Community**: [Join our Discord](https://discord.gg/S7RXUHwg) for real-time support and discussions
|
||||
- 📧 **Email Support**: support@patchmon.net
|
||||
- 📚 **Documentation**: Check our wiki and documentation
|
||||
- 🐛 **Bug Reports**: Use GitHub Issues
|
||||
|
||||
### Community
|
||||
- 🌟 **Star the Project**: Show your support by starring this repository
|
||||
- 🍴 **Fork & Contribute**: Help improve PatchMon
|
||||
- 📢 **Share**: Tell others about PatchMon
|
||||
- 💡 **Feature Requests**: Suggest new features via GitHub Issues
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
@@ -341,6 +257,9 @@ Check out our [public roadmap](https://github.com/users/9technologygroup/project
|
||||
- **@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!
|
||||
@@ -349,9 +268,9 @@ Thank you to all our contributors who help make PatchMon better every day!
|
||||
## 🔗 Links
|
||||
|
||||
- **Website**: [patchmon.net](https://patchmon.net)
|
||||
- **Discord**: [discord.gg/S7RXUHwg](https://discord.gg/S7RXUHwg)
|
||||
- **Discord**: [https://patchmon.net/discord](https://patchmon.net/discord)
|
||||
- **Roadmap**: [GitHub Projects](https://github.com/users/9technologygroup/projects/1)
|
||||
- **Documentation**: [Coming Soon]
|
||||
- **Documentation**: [https://docs.patchmon.net](https://docs.patchmon.net)
|
||||
- **Support**: support@patchmon.net
|
||||
|
||||
---
|
||||
@@ -360,7 +279,7 @@ Thank you to all our contributors who help make PatchMon better every day!
|
||||
|
||||
**Made with ❤️ by the PatchMon Team**
|
||||
|
||||
[](https://discord.gg/S7RXUHwg)
|
||||
[](https://github.com/9technologygroup/patchmon.net)
|
||||
[](https://patchmon.net/discord)
|
||||
[](https://github.com/PatchMon/PatchMon)
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PatchMon Agent Script v1.2.6
|
||||
# PatchMon Agent Script v1.2.7
|
||||
# This script sends package update information to the PatchMon server using API credentials
|
||||
|
||||
# Configuration
|
||||
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
|
||||
API_VERSION="v1"
|
||||
AGENT_VERSION="1.2.6"
|
||||
AGENT_VERSION="1.2.7"
|
||||
CONFIG_FILE="/etc/patchmon/agent.conf"
|
||||
CREDENTIALS_FILE="/etc/patchmon/credentials"
|
||||
LOG_FILE="/var/log/patchmon-agent.log"
|
||||
|
||||
# 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=""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -20,7 +25,10 @@ NC='\033[0m' # No Color
|
||||
|
||||
# Logging function
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
# Try to write to log file, but don't fail if we can't
|
||||
if [[ -w "$(dirname "$LOG_FILE")" ]] 2>/dev/null; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Error handling
|
||||
@@ -30,24 +38,46 @@ error() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Info logging
|
||||
# Info logging (cleaner output - only stdout, no duplicate logging)
|
||||
info() {
|
||||
echo -e "${BLUE}INFO: $1${NC}"
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
log "INFO: $1"
|
||||
}
|
||||
|
||||
# Success logging
|
||||
# Success logging (cleaner output - only stdout, no duplicate logging)
|
||||
success() {
|
||||
echo -e "${GREEN}SUCCESS: $1${NC}"
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
log "SUCCESS: $1"
|
||||
}
|
||||
|
||||
# Warning logging
|
||||
# Warning logging (cleaner output - only stdout, no duplicate logging)
|
||||
warning() {
|
||||
echo -e "${YELLOW}WARNING: $1${NC}"
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
log "WARNING: $1"
|
||||
}
|
||||
|
||||
# Get or generate machine ID
|
||||
get_machine_id() {
|
||||
# Try standard locations 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 UUID or hostname+MAC
|
||||
if command -v dmidecode &> /dev/null; then
|
||||
local uuid=$(dmidecode -s system-uuid 2>/dev/null | tr -d ' -' | tr '[:upper:]' '[:lower:]')
|
||||
if [[ -n "$uuid" && "$uuid" != "notpresent" ]]; then
|
||||
echo "$uuid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
# Last resort: hash hostname + primary MAC address
|
||||
local primary_mac=$(ip link show | grep -oP '(?<=link/ether\s)[0-9a-f:]+' | head -1 | tr -d ':')
|
||||
echo "$HOSTNAME-$primary_mac" | sha256sum | cut -d' ' -f1 | cut -c1-32
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
@@ -55,6 +85,31 @@ check_root() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Verify system datetime and timezone
|
||||
verify_datetime() {
|
||||
info "Verifying system datetime and timezone..."
|
||||
|
||||
# Get current system time
|
||||
local system_time=$(date)
|
||||
local timezone="Unknown"
|
||||
|
||||
# Try to get timezone with timeout protection
|
||||
if command -v timedatectl >/dev/null 2>&1; then
|
||||
timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
|
||||
fi
|
||||
|
||||
# Log datetime info (non-blocking)
|
||||
log "System datetime check - time: $system_time, timezone: $timezone" 2>/dev/null || true
|
||||
|
||||
# Simple check - just log the info, don't block execution
|
||||
if [[ "$timezone" == "Unknown" ]] || [[ -z "$timezone" ]]; then
|
||||
warning "System timezone not configured: $timezone"
|
||||
log "WARNING: System timezone not configured - timezone: $timezone" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Create necessary directories
|
||||
setup_directories() {
|
||||
mkdir -p /etc/patchmon
|
||||
@@ -144,7 +199,7 @@ EOF
|
||||
test_credentials() {
|
||||
load_credentials
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
local response=$(curl $CURL_FLAGS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-ID: $API_ID" \
|
||||
-H "X-API-KEY: $API_KEY" \
|
||||
@@ -550,33 +605,36 @@ get_apt_packages() {
|
||||
local -n packages_ref=$1
|
||||
local -n first_ref=$2
|
||||
|
||||
# Update package lists
|
||||
# Update package lists (use apt-get for older distros; quieter output)
|
||||
apt-get update -qq
|
||||
|
||||
# Get upgradable packages
|
||||
local upgradable=$(apt list --upgradable 2>/dev/null | grep -v "WARNING")
|
||||
# Determine upgradable packages using apt-get simulation (compatible with Ubuntu 18.04)
|
||||
# Example line format:
|
||||
# Inst bash [4.4.18-2ubuntu1] (4.4.18-2ubuntu1.2 Ubuntu:18.04/bionic-updates [amd64])
|
||||
local upgradable_sim=$(apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null | grep "^Inst ")
|
||||
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^([^/]+)/([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+.*[[:space:]]([^[:space:]]+)[[:space:]]*(\[.*\])? ]]; then
|
||||
# Extract package name, current version (in brackets), and available version (first token inside parentheses)
|
||||
if [[ "$line" =~ ^Inst[[:space:]]+([^[:space:]]+)[[:space:]]+\[([^\]]+)\][[:space:]]+\(([^[:space:]]+) ]]; then
|
||||
local package_name="${BASH_REMATCH[1]}"
|
||||
local current_version="${BASH_REMATCH[4]}"
|
||||
local current_version="${BASH_REMATCH[2]}"
|
||||
local available_version="${BASH_REMATCH[3]}"
|
||||
local is_security_update=false
|
||||
|
||||
# Check if it's a security update
|
||||
if echo "$line" | grep -q "security"; then
|
||||
# Mark as security update if the line references a security pocket
|
||||
if echo "$line" | grep -qiE "(-|/)security"; then
|
||||
is_security_update=true
|
||||
fi
|
||||
|
||||
if [[ "$first_ref" == true ]]; then
|
||||
first_ref=false
|
||||
else
|
||||
packages_ref+=","
|
||||
packages_ref+=","
|
||||
fi
|
||||
|
||||
packages_ref+="{\"name\":\"$package_name\",\"currentVersion\":\"$current_version\",\"availableVersion\":\"$available_version\",\"needsUpdate\":true,\"isSecurityUpdate\":$is_security_update}"
|
||||
fi
|
||||
done <<< "$upgradable"
|
||||
done <<< "$upgradable_sim"
|
||||
|
||||
# Get installed packages that are up to date
|
||||
local installed=$(dpkg-query -W -f='${Package} ${Version}\n' | head -100)
|
||||
@@ -608,16 +666,31 @@ get_yum_packages() {
|
||||
fi
|
||||
|
||||
# Get upgradable packages
|
||||
local upgradable=$($package_manager check-update 2>/dev/null | grep -v "^$" | grep -v "^Loaded" | grep -v "^Last metadata" | tail -n +2)
|
||||
local upgradable=$($package_manager check-update 2>/dev/null | grep -v "^$" | grep -v "^Loaded" | grep -v "^Last metadata" | grep -v "^Security" | tail -n +2)
|
||||
|
||||
while IFS= read -r line; do
|
||||
# Skip empty lines and lines with special characters
|
||||
[[ -z "$line" ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
|
||||
if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then
|
||||
local package_name="${BASH_REMATCH[1]}"
|
||||
local available_version="${BASH_REMATCH[2]}"
|
||||
local repo="${BASH_REMATCH[3]}"
|
||||
|
||||
# Sanitize package name and versions (remove any control characters)
|
||||
package_name=$(echo "$package_name" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
|
||||
available_version=$(echo "$available_version" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
|
||||
repo=$(echo "$repo" | tr -d '[:cntrl:]')
|
||||
|
||||
# Skip if package name is empty after sanitization
|
||||
[[ -z "$package_name" ]] && continue
|
||||
|
||||
# Get current version
|
||||
local current_version=$($package_manager list installed "$package_name" 2>/dev/null | grep "^$package_name" | awk '{print $2}')
|
||||
local current_version=$($package_manager list installed "$package_name" 2>/dev/null | grep "^$package_name" | awk '{print $2}' | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
|
||||
|
||||
# Skip if we couldn't get current version
|
||||
[[ -z "$current_version" ]] && current_version="unknown"
|
||||
|
||||
local is_security_update=false
|
||||
if echo "$repo" | grep -q "security"; then
|
||||
@@ -635,13 +708,25 @@ get_yum_packages() {
|
||||
done <<< "$upgradable"
|
||||
|
||||
# Get some installed packages that are up to date
|
||||
local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed" | head -100)
|
||||
local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed")
|
||||
|
||||
while IFS= read -r line; do
|
||||
# Skip empty lines
|
||||
[[ -z "$line" ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
|
||||
if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then
|
||||
local package_name="${BASH_REMATCH[1]}"
|
||||
local version="${BASH_REMATCH[2]}"
|
||||
|
||||
# Sanitize package name and version
|
||||
package_name=$(echo "$package_name" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
|
||||
version=$(echo "$version" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
|
||||
|
||||
# Skip if package name is empty after sanitization
|
||||
[[ -z "$package_name" ]] && continue
|
||||
[[ -z "$version" ]] && version="unknown"
|
||||
|
||||
# Check if this package is not in the upgrade list
|
||||
if ! echo "$upgradable" | grep -q "^$package_name "; then
|
||||
if [[ "$first_ref" == true ]]; then
|
||||
@@ -675,11 +760,21 @@ get_hardware_info() {
|
||||
|
||||
# Memory Information
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
ram_installed=$(free -g | grep "^Mem:" | awk '{print $2}')
|
||||
swap_size=$(free -g | grep "^Swap:" | awk '{print $2}')
|
||||
# Use free -m to get MB, then convert to GB with decimal precision
|
||||
ram_installed=$(free -m | grep "^Mem:" | awk '{printf "%.2f", $2/1024}')
|
||||
swap_size=$(free -m | grep "^Swap:" | awk '{printf "%.2f", $2/1024}')
|
||||
elif [[ -f /proc/meminfo ]]; then
|
||||
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
|
||||
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
|
||||
# Convert KB to GB with decimal precision
|
||||
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{printf "%.2f", $2/1048576}')
|
||||
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{printf "%.2f", $2/1048576}')
|
||||
fi
|
||||
|
||||
# Ensure minimum value of 0.01GB to prevent 0 values
|
||||
if (( $(echo "$ram_installed < 0.01" | bc -l) )); then
|
||||
ram_installed="0.01"
|
||||
fi
|
||||
if (( $(echo "$swap_size < 0" | bc -l) )); then
|
||||
swap_size="0"
|
||||
fi
|
||||
|
||||
# Disk Information
|
||||
@@ -737,8 +832,16 @@ get_system_info() {
|
||||
# SELinux Status
|
||||
if command -v getenforce >/dev/null 2>&1; then
|
||||
selinux_status=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
||||
# Map "enforcing" to "enabled" for server validation
|
||||
if [[ "$selinux_status" == "enforcing" ]]; then
|
||||
selinux_status="enabled"
|
||||
fi
|
||||
elif [[ -f /etc/selinux/config ]]; then
|
||||
selinux_status=$(grep "^SELINUX=" /etc/selinux/config | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]')
|
||||
# Map "enforcing" to "enabled" for server validation
|
||||
if [[ "$selinux_status" == "enforcing" ]]; then
|
||||
selinux_status="enabled"
|
||||
fi
|
||||
else
|
||||
selinux_status="disabled"
|
||||
fi
|
||||
@@ -768,25 +871,25 @@ get_system_info() {
|
||||
send_update() {
|
||||
load_credentials
|
||||
|
||||
info "Collecting package information..."
|
||||
local packages_json=$(get_package_info)
|
||||
|
||||
info "Collecting repository information..."
|
||||
local repositories_json=$(get_repository_info)
|
||||
|
||||
info "Collecting hardware information..."
|
||||
local hardware_json=$(get_hardware_info)
|
||||
|
||||
info "Collecting network information..."
|
||||
local network_json=$(get_network_info)
|
||||
# Verify datetime before proceeding
|
||||
if ! verify_datetime; then
|
||||
warning "Datetime verification failed, but continuing with update..."
|
||||
fi
|
||||
|
||||
info "Collecting system information..."
|
||||
local packages_json=$(get_package_info)
|
||||
local repositories_json=$(get_repository_info)
|
||||
local hardware_json=$(get_hardware_info)
|
||||
local network_json=$(get_network_info)
|
||||
local system_json=$(get_system_info)
|
||||
|
||||
info "Sending update to PatchMon server..."
|
||||
|
||||
# Merge all JSON objects into one
|
||||
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
|
||||
# Get machine ID
|
||||
local machine_id=$(get_machine_id)
|
||||
|
||||
# Create the base payload and merge with system info
|
||||
local base_payload=$(cat <<EOF
|
||||
{
|
||||
@@ -797,7 +900,8 @@ send_update() {
|
||||
"hostname": "$HOSTNAME",
|
||||
"ip": "$IP_ADDRESS",
|
||||
"architecture": "$ARCHITECTURE",
|
||||
"agentVersion": "$AGENT_VERSION"
|
||||
"agentVersion": "$AGENT_VERSION",
|
||||
"machineId": "$machine_id"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
@@ -806,7 +910,7 @@ EOF
|
||||
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
|
||||
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
local response=$(curl $CURL_FLAGS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-ID: $API_ID" \
|
||||
-H "X-API-KEY: $API_KEY" \
|
||||
@@ -815,52 +919,35 @@ EOF
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
if echo "$response" | grep -q "success"; then
|
||||
success "Update sent successfully"
|
||||
echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2 | xargs -I {} info "Processed {} packages"
|
||||
local packages_count=$(echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2)
|
||||
success "Update sent successfully (${packages_count} packages processed)"
|
||||
|
||||
# Check for auto-update instructions (look specifically in autoUpdate section)
|
||||
if echo "$response" | grep -q '"autoUpdate":{'; then
|
||||
local auto_update_section=$(echo "$response" | grep -o '"autoUpdate":{[^}]*}')
|
||||
local should_update=$(echo "$auto_update_section" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
|
||||
if [[ "$should_update" == "true" ]]; then
|
||||
local latest_version=$(echo "$auto_update_section" | grep -o '"latestVersion":"[^"]*' | cut -d'"' -f4)
|
||||
local current_version=$(echo "$auto_update_section" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
|
||||
local update_message=$(echo "$auto_update_section" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
|
||||
|
||||
info "Auto-update detected: $update_message"
|
||||
info "Current version: $current_version, Latest version: $latest_version"
|
||||
|
||||
# Automatically run update-agent command
|
||||
info "Automatically updating agent to latest version..."
|
||||
# Check if auto-update is enabled and check for agent updates locally
|
||||
if check_auto_update_enabled; then
|
||||
info "Checking for agent updates..."
|
||||
if check_agent_update_needed; then
|
||||
info "Agent update available, updating..."
|
||||
if "$0" update-agent; then
|
||||
success "Agent auto-update completed successfully"
|
||||
success "Agent updated successfully"
|
||||
else
|
||||
warning "Agent auto-update failed, but data was sent successfully"
|
||||
warning "Agent update failed, but data was sent successfully"
|
||||
fi
|
||||
else
|
||||
info "Agent is up to date"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for crontab update instructions (look specifically in crontabUpdate section)
|
||||
if echo "$response" | grep -q '"crontabUpdate":{'; then
|
||||
local crontab_update_section=$(echo "$response" | grep -o '"crontabUpdate":{[^}]*}')
|
||||
local should_update_crontab=$(echo "$crontab_update_section" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
|
||||
if [[ "$should_update_crontab" == "true" ]]; then
|
||||
local crontab_message=$(echo "$crontab_update_section" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
|
||||
local crontab_command=$(echo "$crontab_update_section" | grep -o '"command":"[^"]*' | cut -d'"' -f4)
|
||||
|
||||
if [[ -n "$crontab_message" ]]; then
|
||||
info "Crontab update detected: $crontab_message"
|
||||
fi
|
||||
|
||||
if [[ "$crontab_command" == "update-crontab" ]]; then
|
||||
info "Automatically updating crontab with new interval..."
|
||||
if "$0" update-crontab; then
|
||||
success "Crontab updated successfully"
|
||||
else
|
||||
warning "Crontab update failed, but data was sent successfully"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Automatically check if crontab needs updating based on server settings
|
||||
info "Checking crontab configuration..."
|
||||
"$0" update-crontab
|
||||
local crontab_exit_code=$?
|
||||
if [[ $crontab_exit_code -eq 0 ]]; then
|
||||
success "Crontab updated successfully"
|
||||
elif [[ $crontab_exit_code -eq 2 ]]; then
|
||||
# Already up to date - no additional message needed
|
||||
true
|
||||
else
|
||||
warning "Crontab update failed, but data was sent successfully"
|
||||
fi
|
||||
else
|
||||
error "Update failed: $response"
|
||||
@@ -874,7 +961,7 @@ EOF
|
||||
ping_server() {
|
||||
load_credentials
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
local response=$(curl $CURL_FLAGS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-ID: $API_ID" \
|
||||
-H "X-API-KEY: $API_KEY" \
|
||||
@@ -887,7 +974,7 @@ ping_server() {
|
||||
info "Connected as host: $hostname"
|
||||
fi
|
||||
|
||||
# Check for crontab update trigger
|
||||
# Check for crontab update instructions
|
||||
local should_update_crontab=$(echo "$response" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
|
||||
if [[ "$should_update_crontab" == "true" ]]; then
|
||||
local message=$(echo "$response" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
|
||||
@@ -898,11 +985,16 @@ ping_server() {
|
||||
fi
|
||||
|
||||
if [[ "$command" == "update-crontab" ]]; then
|
||||
info "Automatically updating crontab with new interval..."
|
||||
if "$0" update-crontab; then
|
||||
info "Updating crontab with new interval..."
|
||||
"$0" update-crontab
|
||||
local crontab_exit_code=$?
|
||||
if [[ $crontab_exit_code -eq 0 ]]; then
|
||||
success "Crontab updated successfully"
|
||||
elif [[ $crontab_exit_code -eq 2 ]]; then
|
||||
# Already up to date - no additional message needed
|
||||
true
|
||||
else
|
||||
warning "Crontab update failed, but ping was successful"
|
||||
warning "Crontab update failed, but data was sent successfully"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -917,7 +1009,7 @@ check_version() {
|
||||
|
||||
info "Checking for agent updates..."
|
||||
|
||||
local response=$(curl -s -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
|
||||
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
local current_version=$(echo "$response" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
|
||||
@@ -946,59 +1038,147 @@ check_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if auto-update is enabled (both globally and for this host)
|
||||
check_auto_update_enabled() {
|
||||
# Get settings from server using API credentials
|
||||
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/settings" 2>/dev/null)
|
||||
if [[ $? -ne 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if both global and host auto-update are enabled
|
||||
local global_auto_update=$(echo "$response" | grep -o '"auto_update":true' | cut -d':' -f2)
|
||||
local host_auto_update=$(echo "$response" | grep -o '"host_auto_update":true' | cut -d':' -f2)
|
||||
|
||||
if [[ "$global_auto_update" == "true" && "$host_auto_update" == "true" ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if agent update is needed (internal function for auto-update)
|
||||
check_agent_update_needed() {
|
||||
# Get current agent timestamp
|
||||
local current_timestamp=0
|
||||
if [[ -f "$0" ]]; then
|
||||
current_timestamp=$(stat -c %Y "$0" 2>/dev/null || stat -f %m "$0" 2>/dev/null || echo "0")
|
||||
fi
|
||||
|
||||
# Get server agent info using API credentials
|
||||
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp" 2>/dev/null)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
local server_version=$(echo "$response" | grep -o '"version":"[^"]*' | cut -d'"' -f4)
|
||||
local server_timestamp=$(echo "$response" | grep -o '"timestamp":[0-9]*' | cut -d':' -f2)
|
||||
local server_exists=$(echo "$response" | grep -o '"exists":true' | cut -d':' -f2)
|
||||
|
||||
if [[ "$server_exists" != "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if update is needed
|
||||
if [[ "$server_version" != "$AGENT_VERSION" ]]; then
|
||||
return 0 # Update needed due to version mismatch
|
||||
elif [[ "$server_timestamp" -gt "$current_timestamp" ]]; then
|
||||
return 0 # Update needed due to newer timestamp
|
||||
else
|
||||
return 1 # No update needed
|
||||
fi
|
||||
else
|
||||
return 1 # Failed to check
|
||||
fi
|
||||
}
|
||||
|
||||
# Check for agent updates based on version and timestamp (interactive command)
|
||||
check_agent_update() {
|
||||
load_credentials
|
||||
|
||||
info "Checking for agent updates..."
|
||||
|
||||
# Get current agent timestamp
|
||||
local current_timestamp=0
|
||||
if [[ -f "$0" ]]; then
|
||||
current_timestamp=$(stat -c %Y "$0" 2>/dev/null || stat -f %m "$0" 2>/dev/null || echo "0")
|
||||
fi
|
||||
|
||||
# Get server agent info using API credentials
|
||||
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp")
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
local server_version=$(echo "$response" | grep -o '"version":"[^"]*' | cut -d'"' -f4)
|
||||
local server_timestamp=$(echo "$response" | grep -o '"timestamp":[0-9]*' | cut -d':' -f2)
|
||||
local server_exists=$(echo "$response" | grep -o '"exists":true' | cut -d':' -f2)
|
||||
|
||||
if [[ "$server_exists" != "true" ]]; then
|
||||
warning "No agent script found on server"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Current agent version: $AGENT_VERSION (timestamp: $current_timestamp)"
|
||||
info "Server agent version: $server_version (timestamp: $server_timestamp)"
|
||||
|
||||
# Check if update is needed
|
||||
if [[ "$server_version" != "$AGENT_VERSION" ]]; then
|
||||
info "Version mismatch detected - update needed"
|
||||
return 0
|
||||
elif [[ "$server_timestamp" -gt "$current_timestamp" ]]; then
|
||||
info "Server script is newer - update needed"
|
||||
return 0
|
||||
else
|
||||
info "Agent is up to date"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
error "Failed to check agent timestamp from server"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Update agent script
|
||||
update_agent() {
|
||||
load_credentials
|
||||
|
||||
info "Updating agent script..."
|
||||
|
||||
local response=$(curl -s -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
|
||||
local download_url="$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/download"
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
local download_url=$(echo "$response" | grep -o '"downloadUrl":"[^"]*' | cut -d'"' -f4)
|
||||
|
||||
if [[ -z "$download_url" ]]; then
|
||||
download_url="$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/download"
|
||||
elif [[ "$download_url" =~ ^/ ]]; then
|
||||
# If download_url is relative, prepend the server URL
|
||||
download_url="$PATCHMON_SERVER$download_url"
|
||||
fi
|
||||
|
||||
info "Downloading latest agent from: $download_url"
|
||||
|
||||
# Create backup of current script
|
||||
cp "$0" "$0.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
# Download new version
|
||||
if curl -s -o "/tmp/patchmon-agent-new.sh" "$download_url"; then
|
||||
# Verify the downloaded script is valid
|
||||
if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then
|
||||
# Replace current script
|
||||
mv "/tmp/patchmon-agent-new.sh" "$0"
|
||||
chmod +x "$0"
|
||||
success "Agent updated successfully"
|
||||
info "Backup saved as: $0.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
# Get the new version number
|
||||
local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2)
|
||||
info "Updated to version: $new_version"
|
||||
|
||||
# Automatically run update to send new information to PatchMon
|
||||
info "Sending updated information to PatchMon..."
|
||||
if "$0" update; then
|
||||
success "Successfully sent updated information to PatchMon"
|
||||
else
|
||||
warning "Failed to send updated information to PatchMon (this is not critical)"
|
||||
fi
|
||||
info "Downloading latest agent from: $download_url"
|
||||
|
||||
# Clean up old backups (keep only last 3)
|
||||
ls -t "$0.backup."* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Create backup of current script
|
||||
local backup_file="$0.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$0" "$backup_file"
|
||||
|
||||
# Download new version using API credentials
|
||||
if curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -o "/tmp/patchmon-agent-new.sh" "$download_url"; then
|
||||
# Verify the downloaded script is valid
|
||||
if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then
|
||||
# Replace current script
|
||||
mv "/tmp/patchmon-agent-new.sh" "$0"
|
||||
chmod +x "$0"
|
||||
success "Agent updated successfully"
|
||||
info "Backup saved as: $backup_file"
|
||||
|
||||
# Get the new version number
|
||||
local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2)
|
||||
info "Updated to version: $new_version"
|
||||
|
||||
# Automatically run update to send new information to PatchMon
|
||||
info "Sending updated information to PatchMon..."
|
||||
if "$0" update; then
|
||||
success "Successfully sent updated information to PatchMon"
|
||||
else
|
||||
error "Downloaded script is invalid"
|
||||
rm -f "/tmp/patchmon-agent-new.sh"
|
||||
warning "Failed to send updated information to PatchMon (this is not critical)"
|
||||
fi
|
||||
else
|
||||
error "Failed to download new agent script"
|
||||
error "Downloaded script is invalid"
|
||||
rm -f "/tmp/patchmon-agent-new.sh"
|
||||
fi
|
||||
else
|
||||
error "Failed to get update information"
|
||||
error "Failed to download new agent script"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1006,33 +1186,59 @@ update_agent() {
|
||||
update_crontab() {
|
||||
load_credentials
|
||||
info "Updating crontab with current policy..."
|
||||
local response=$(curl -s -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval")
|
||||
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval")
|
||||
if [[ $? -eq 0 ]]; then
|
||||
local update_interval=$(echo "$response" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2)
|
||||
# Fallback if not found
|
||||
if [[ -z "$update_interval" ]]; then
|
||||
update_interval=60
|
||||
fi
|
||||
# Normalize interval: 5-59 valid, otherwise snap to hour presets
|
||||
if [[ $update_interval -lt 5 ]]; then
|
||||
update_interval=5
|
||||
elif [[ $update_interval -gt 1440 ]]; then
|
||||
update_interval=1440
|
||||
fi
|
||||
if [[ -n "$update_interval" ]]; then
|
||||
# Generate the expected crontab entry
|
||||
local expected_crontab=""
|
||||
if [[ $update_interval -eq 60 ]]; then
|
||||
# Hourly updates starting at current minute
|
||||
local current_minute=$(date +%M)
|
||||
expected_crontab="$current_minute * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
|
||||
else
|
||||
# Custom interval updates
|
||||
if [[ $update_interval -lt 60 ]]; then
|
||||
# Every N minutes (5-59)
|
||||
expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
|
||||
else
|
||||
# Hour-based schedules
|
||||
if [[ $update_interval -eq 60 ]]; then
|
||||
# Hourly updates starting at current minute to spread load
|
||||
local current_minute=$(date +%M)
|
||||
expected_crontab="$current_minute * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
|
||||
else
|
||||
# For 120, 180, 360, 720, 1440 -> every H hours at minute 0
|
||||
local hours=$((update_interval / 60))
|
||||
expected_crontab="0 */$hours * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get current crontab
|
||||
local current_crontab=$(crontab -l 2>/dev/null | grep "patchmon-agent.sh update" | head -1)
|
||||
# Get current crontab (without patchmon entries)
|
||||
local current_crontab_without_patchmon=$(crontab -l 2>/dev/null | grep -v "/usr/local/bin/patchmon-agent.sh update" || true)
|
||||
local current_patchmon_entry=$(crontab -l 2>/dev/null | grep "/usr/local/bin/patchmon-agent.sh update" | head -1)
|
||||
|
||||
# Check if crontab needs updating
|
||||
if [[ "$current_crontab" == "$expected_crontab" ]]; then
|
||||
if [[ "$current_patchmon_entry" == "$expected_crontab" ]]; then
|
||||
info "Crontab is already up to date (interval: $update_interval minutes)"
|
||||
return 0
|
||||
return 2 # Special return code for "already up to date"
|
||||
fi
|
||||
|
||||
info "Setting update interval to $update_interval minutes"
|
||||
echo "$expected_crontab" | crontab -
|
||||
success "Crontab updated successfully"
|
||||
|
||||
# Combine existing cron (without patchmon entries) + new patchmon entry
|
||||
{
|
||||
if [[ -n "$current_crontab_without_patchmon" ]]; then
|
||||
echo "$current_crontab_without_patchmon"
|
||||
fi
|
||||
echo "$expected_crontab"
|
||||
} | crontab -
|
||||
|
||||
success "Crontab updated successfully (duplicates removed)"
|
||||
else
|
||||
error "Could not determine update interval from server"
|
||||
fi
|
||||
@@ -1144,7 +1350,7 @@ main() {
|
||||
check_root
|
||||
setup_directories
|
||||
load_config
|
||||
configure_credentials "$2" "$3"
|
||||
configure_credentials "$2" "$3" "$4"
|
||||
;;
|
||||
"test")
|
||||
check_root
|
||||
@@ -1175,6 +1381,11 @@ main() {
|
||||
load_config
|
||||
check_version
|
||||
;;
|
||||
"check-agent-update")
|
||||
setup_directories
|
||||
load_config
|
||||
check_agent_update
|
||||
;;
|
||||
"update-agent")
|
||||
check_root
|
||||
setup_directories
|
||||
@@ -1192,22 +1403,23 @@ main() {
|
||||
;;
|
||||
*)
|
||||
echo "PatchMon Agent v$AGENT_VERSION - API Credential Based"
|
||||
echo "Usage: $0 {configure|test|update|ping|config|check-version|update-agent|update-crontab|diagnostics}"
|
||||
echo "Usage: $0 {configure|test|update|ping|config|check-version|check-agent-update|update-agent|update-crontab|diagnostics}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " configure <API_ID> <API_KEY> - Configure API credentials for this host"
|
||||
echo " configure <API_ID> <API_KEY> [SERVER_URL] - Configure API credentials for this host"
|
||||
echo " test - Test API credentials connectivity"
|
||||
echo " update - Send package update information to server"
|
||||
echo " ping - Test connectivity to server"
|
||||
echo " config - Show current configuration"
|
||||
echo " check-version - Check for agent updates"
|
||||
echo " check-agent-update - Check for agent updates using timestamp comparison"
|
||||
echo " update-agent - Update agent to latest version"
|
||||
echo " update-crontab - Update crontab with current policy"
|
||||
echo " diagnostics - Show detailed system diagnostics"
|
||||
echo ""
|
||||
echo "Setup Process:"
|
||||
echo " 1. Contact your PatchMon administrator to create a host entry"
|
||||
echo " 2. Run: $0 configure <API_ID> <API_KEY> (provided by admin)"
|
||||
echo " 2. Run: $0 configure <API_ID> <API_KEY> [SERVER_URL] (provided by admin)"
|
||||
echo " 3. Run: $0 test (to verify connection)"
|
||||
echo " 4. Run: $0 update (to send initial package data)"
|
||||
echo ""
|
||||
|
||||
@@ -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,103 +40,276 @@ if [[ $EUID -ne 0 ]]; then
|
||||
error "This script must be run as root (use sudo)"
|
||||
fi
|
||||
|
||||
# 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 -e "${BLUE}📅 Current System Date/Time:${NC}"
|
||||
echo " • Date/Time: $system_time"
|
||||
echo " • Timezone: $timezone"
|
||||
echo ""
|
||||
|
||||
# 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
|
||||
|
||||
# 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 ""
|
||||
|
||||
# Detect package manager and install jq
|
||||
# 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
|
||||
apt-get update >/dev/null 2>&1
|
||||
apt-get install -y jq curl >/dev/null 2>&1
|
||||
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
|
||||
yum install -y jq curl >/dev/null 2>&1
|
||||
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
|
||||
dnf install -y jq curl >/dev/null 2>&1
|
||||
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
|
||||
zypper install -y jq curl >/dev/null 2>&1
|
||||
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
|
||||
pacman -S --noconfirm jq curl >/dev/null 2>&1
|
||||
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
|
||||
apk add --no-cache jq curl >/dev/null 2>&1
|
||||
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' and 'curl' are installed manually."
|
||||
warning "Could not detect package manager. Please ensure 'jq', 'curl', and 'bc' are installed manually."
|
||||
fi
|
||||
|
||||
# Verify jq installation
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
error "Failed to install 'jq'. Please install it manually: https://stedolan.github.io/jq/download/"
|
||||
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
|
||||
|
||||
success "Dependencies installed successfully!"
|
||||
# Step 2: Create credentials file
|
||||
info "🔐 Creating API credentials file..."
|
||||
|
||||
# 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}"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo "curl -sSL http://patchmon.example.com/api/v1/hosts/install | bash -s -- http://patchmon.example.com patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
echo ""
|
||||
echo "Contact your PatchMon administrator to get your API credentials."
|
||||
exit 1
|
||||
# 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
|
||||
|
||||
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"
|
||||
fi
|
||||
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"
|
||||
|
||||
# Create credentials file
|
||||
info "🔐 Setting up API credentials..."
|
||||
cat > /etc/patchmon/credentials << EOF
|
||||
# PatchMon API Credentials
|
||||
# Generated on $(date)
|
||||
@@ -139,53 +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 " • Dependencies installed: jq, curl"
|
||||
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
|
||||
@@ -23,3 +25,9 @@ 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
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
{
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.2.6",
|
||||
"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",
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0",
|
||||
"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,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';
|
||||
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;
|
||||
|
||||
@@ -7,19 +7,6 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model agent_versions {
|
||||
id String @id
|
||||
version String @unique
|
||||
is_current Boolean @default(false)
|
||||
release_notes String?
|
||||
download_url String?
|
||||
min_server_version String?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
is_default Boolean @default(false)
|
||||
script_content String?
|
||||
}
|
||||
|
||||
model dashboard_preferences {
|
||||
id String @id
|
||||
user_id String
|
||||
@@ -34,13 +21,14 @@ model dashboard_preferences {
|
||||
}
|
||||
|
||||
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[]
|
||||
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 host_packages {
|
||||
@@ -72,7 +60,8 @@ model host_repositories {
|
||||
|
||||
model hosts {
|
||||
id String @id
|
||||
friendly_name String @unique
|
||||
machine_id String @unique
|
||||
friendly_name String
|
||||
ip String?
|
||||
os_type String
|
||||
os_version String
|
||||
@@ -99,10 +88,15 @@ model hosts {
|
||||
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 packages {
|
||||
@@ -169,6 +163,10 @@ model settings {
|
||||
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 update_history {
|
||||
@@ -183,19 +181,63 @@ model update_history {
|
||||
}
|
||||
|
||||
model users {
|
||||
id String @id
|
||||
username String @unique
|
||||
email String @unique
|
||||
password_hash String
|
||||
first_name String?
|
||||
last_name String?
|
||||
role String @default("admin")
|
||||
is_active Boolean @default(true)
|
||||
last_login DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
tfa_backup_codes String?
|
||||
tfa_enabled Boolean @default(false)
|
||||
tfa_secret String?
|
||||
dashboard_preferences dashboard_preferences[]
|
||||
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?
|
||||
last_activity DateTime @default(now())
|
||||
expires_at DateTime
|
||||
created_at DateTime @default(now())
|
||||
is_revoked Boolean @default(false)
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([user_id])
|
||||
@@index([refresh_token])
|
||||
@@index([expires_at])
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -3,106 +3,127 @@
|
||||
* Optimizes connection pooling to prevent "too many connections" errors
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
// Parse DATABASE_URL and add connection pooling parameters
|
||||
function getOptimizedDatabaseUrl() {
|
||||
const originalUrl = process.env.DATABASE_URL;
|
||||
const originalUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!originalUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is required');
|
||||
}
|
||||
if (!originalUrl) {
|
||||
throw new Error("DATABASE_URL environment variable is required");
|
||||
}
|
||||
|
||||
// Parse the URL
|
||||
const url = new URL(originalUrl);
|
||||
// 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
|
||||
// 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();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Create optimized Prisma client
|
||||
function createPrismaClient() {
|
||||
const optimizedUrl = getOptimizedDatabaseUrl();
|
||||
const optimizedUrl = getOptimizedDatabaseUrl();
|
||||
|
||||
return new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: optimizedUrl
|
||||
}
|
||||
},
|
||||
log: process.env.NODE_ENV === 'development'
|
||||
? ['query', 'info', 'warn', 'error']
|
||||
: ['warn', 'error'],
|
||||
errorFormat: 'pretty'
|
||||
});
|
||||
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;
|
||||
}
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Database connection failed:", error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for database to be available with retry logic
|
||||
async function waitForDatabase(prisma, options = {}) {
|
||||
const maxAttempts = options.maxAttempts || parseInt(process.env.PM_DB_CONN_MAX_ATTEMPTS) || 30;
|
||||
const waitInterval = options.waitInterval || parseInt(process.env.PM_DB_CONN_WAIT_INTERVAL) || 2;
|
||||
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;
|
||||
|
||||
console.log(`Waiting for database connection (max ${maxAttempts} attempts, ${waitInterval}s interval)...`);
|
||||
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) {
|
||||
console.log(`Database connected successfully after ${attempt} attempt(s)`);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// checkDatabaseConnection already logs the error
|
||||
}
|
||||
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) {
|
||||
console.log(`⏳ Database not ready (attempt ${attempt}/${maxAttempts}), retrying in ${waitInterval}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitInterval * 1000));
|
||||
}
|
||||
}
|
||||
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`);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
createPrismaClient,
|
||||
checkDatabaseConnection,
|
||||
waitForDatabase,
|
||||
disconnectPrisma,
|
||||
getOptimizedDatabaseUrl,
|
||||
};
|
||||
|
||||
@@ -1,106 +1,121 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const {
|
||||
validate_session,
|
||||
update_session_activity,
|
||||
} = 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.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
|
||||
}
|
||||
});
|
||||
// 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.is_active) {
|
||||
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.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
last_login: new Date(),
|
||||
updated_at: 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);
|
||||
|
||||
// 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;
|
||||
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.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 (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.is_active) {
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
optionalAuth
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
optionalAuth,
|
||||
};
|
||||
|
||||
@@ -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.role_permissions.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_', '').replace('_', ' ')}`
|
||||
});
|
||||
}
|
||||
// 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 - 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');
|
||||
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,206 +1,367 @@
|
||||
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 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 }
|
||||
});
|
||||
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
|
||||
};
|
||||
}
|
||||
// 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
|
||||
};
|
||||
}
|
||||
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 },
|
||||
async function createDefaultDashboardPreferences(userId, userRole = "user") {
|
||||
try {
|
||||
// Get user's actual permissions
|
||||
const permissions = await getUserPermissions(userRole);
|
||||
|
||||
// Package-related cards
|
||||
{ cardId: 'totalOutdatedPackages', requiredPermission: 'can_view_packages', order: 2 },
|
||||
{ cardId: 'securityUpdates', requiredPermission: 'can_view_packages', order: 3 },
|
||||
// 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,
|
||||
},
|
||||
|
||||
// Host-related cards (continued)
|
||||
{ cardId: 'totalHostGroups', requiredPermission: 'can_view_hosts', order: 4 },
|
||||
{ cardId: 'upToDateHosts', requiredPermission: 'can_view_hosts', order: 5 },
|
||||
// Package-related cards
|
||||
{
|
||||
cardId: "totalOutdatedPackages",
|
||||
requiredPermission: "can_view_packages",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
cardId: "securityUpdates",
|
||||
requiredPermission: "can_view_packages",
|
||||
order: 3,
|
||||
},
|
||||
|
||||
// Repository-related cards
|
||||
{ cardId: 'totalRepos', requiredPermission: 'can_view_hosts', order: 6 },
|
||||
// Host-related cards (continued)
|
||||
{
|
||||
cardId: "totalHostGroups",
|
||||
requiredPermission: "can_view_hosts",
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
cardId: "upToDateHosts",
|
||||
requiredPermission: "can_view_hosts",
|
||||
order: 5,
|
||||
},
|
||||
|
||||
// User management cards (admin only)
|
||||
{ cardId: 'totalUsers', requiredPermission: 'can_view_users', order: 7 },
|
||||
// Repository-related cards
|
||||
{ cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 6 },
|
||||
|
||||
// System/Report cards
|
||||
{ cardId: 'osDistribution', requiredPermission: 'can_view_reports', order: 8 },
|
||||
{ cardId: 'osDistributionBar', requiredPermission: 'can_view_reports', order: 9 },
|
||||
{ cardId: 'recentCollection', requiredPermission: 'can_view_hosts', order: 10 },
|
||||
{ cardId: 'updateStatus', requiredPermission: 'can_view_reports', order: 11 },
|
||||
{ cardId: 'packagePriority', requiredPermission: 'can_view_packages', order: 12 },
|
||||
{ cardId: 'recentUsers', requiredPermission: 'can_view_users', order: 13 },
|
||||
{ cardId: 'quickStats', requiredPermission: 'can_view_dashboard', order: 14 }
|
||||
];
|
||||
// User management cards (admin only)
|
||||
{ cardId: "totalUsers", requiredPermission: "can_view_users", order: 7 },
|
||||
|
||||
// Filter cards based on user's permissions
|
||||
const allowedCards = allCards.filter(card => {
|
||||
return permissions[card.requiredPermission] === true;
|
||||
});
|
||||
// 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: "recentUsers",
|
||||
requiredPermission: "can_view_users",
|
||||
order: 14,
|
||||
},
|
||||
{
|
||||
cardId: "quickStats",
|
||||
requiredPermission: "can_view_dashboard",
|
||||
order: 15,
|
||||
},
|
||||
];
|
||||
|
||||
// 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()
|
||||
}));
|
||||
// Filter cards based on user's permissions
|
||||
const allowedCards = allCards.filter((card) => {
|
||||
return permissions[card.requiredPermission] === true;
|
||||
});
|
||||
|
||||
await prisma.dashboard_preferences.createMany({
|
||||
data: preferencesData
|
||||
});
|
||||
// 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(),
|
||||
}));
|
||||
|
||||
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
|
||||
}
|
||||
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.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' });
|
||||
}
|
||||
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.dashboard_preferences.deleteMany({
|
||||
where: { user_id: userId }
|
||||
});
|
||||
// Delete existing preferences for this user
|
||||
await prisma.dashboard_preferences.deleteMany({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
// 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()
|
||||
}));
|
||||
// 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.dashboard_preferences.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 {
|
||||
// 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: 'hostsNeedingUpdates', 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: 'recentCollection', title: 'Recent Collection', icon: 'Server', enabled: true, order: 10 },
|
||||
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 11 },
|
||||
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 12 },
|
||||
{ cardId: 'recentUsers', title: 'Recent Users Logged in', icon: 'Users', enabled: true, order: 13 },
|
||||
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 14 }
|
||||
];
|
||||
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: "recentUsers",
|
||||
title: "Recent Users Logged in",
|
||||
icon: "Users",
|
||||
enabled: true,
|
||||
order: 14,
|
||||
},
|
||||
{
|
||||
cardId: "quickStats",
|
||||
title: "Quick Stats",
|
||||
icon: "TrendingUp",
|
||||
enabled: true,
|
||||
order: 15,
|
||||
},
|
||||
];
|
||||
|
||||
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, createDefaultDashboardPreferences };
|
||||
|
||||
@@ -1,426 +1,459 @@
|
||||
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 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?.update_interval || 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,
|
||||
offlineHosts,
|
||||
totalHostGroups,
|
||||
totalUsers,
|
||||
totalRepos,
|
||||
osDistribution,
|
||||
updateTrends
|
||||
] = await Promise.all([
|
||||
// Total hosts count (all hosts regardless of status)
|
||||
prisma.hosts.count(),
|
||||
// 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.hosts.count({
|
||||
where: {
|
||||
host_packages: {
|
||||
some: {
|
||||
needs_update: 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.host_packages.count({
|
||||
where: { needs_update: 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.hosts.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
last_update: {
|
||||
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.host_packages.count({
|
||||
where: {
|
||||
needs_update: true,
|
||||
is_security_update: true
|
||||
}
|
||||
}),
|
||||
// Total outdated packages across all hosts
|
||||
prisma.host_packages.count({
|
||||
where: { needs_update: true },
|
||||
}),
|
||||
|
||||
// Offline/Stale hosts (not updated within 3x the update interval)
|
||||
prisma.hosts.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
last_update: {
|
||||
lt: moment(now).subtract(updateIntervalMinutes * 3, 'minutes').toDate()
|
||||
}
|
||||
}
|
||||
}),
|
||||
// Errored hosts (not updated within threshold based on update interval)
|
||||
prisma.hosts.count({
|
||||
where: {
|
||||
status: "active",
|
||||
last_update: {
|
||||
lt: thresholdTime,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Total host groups count
|
||||
prisma.host_groups.count(),
|
||||
// Security updates count
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Total users count
|
||||
prisma.users.count(),
|
||||
// Offline/Stale hosts (not updated within 3x the update interval)
|
||||
prisma.hosts.count({
|
||||
where: {
|
||||
status: "active",
|
||||
last_update: {
|
||||
lt: moment(now)
|
||||
.subtract(updateIntervalMinutes * 3, "minutes")
|
||||
.toDate(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Total repositories count
|
||||
prisma.repositories.count(),
|
||||
// Total host groups count
|
||||
prisma.host_groups.count(),
|
||||
|
||||
// OS distribution for pie chart
|
||||
prisma.hosts.groupBy({
|
||||
by: ['os_type'],
|
||||
where: { status: 'active' },
|
||||
_count: {
|
||||
os_type: true
|
||||
}
|
||||
}),
|
||||
// Total users count
|
||||
prisma.users.count(),
|
||||
|
||||
// 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
|
||||
}
|
||||
})
|
||||
]);
|
||||
// Total repositories count
|
||||
prisma.repositories.count(),
|
||||
|
||||
// Format OS distribution for pie chart
|
||||
const osDistributionFormatted = osDistribution.map(item => ({
|
||||
name: item.os_type,
|
||||
count: item._count.os_type
|
||||
}));
|
||||
// OS distribution for pie chart
|
||||
prisma.hosts.groupBy({
|
||||
by: ["os_type"],
|
||||
where: { status: "active" },
|
||||
_count: {
|
||||
os_type: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Calculate update status distribution
|
||||
const updateStatusDistribution = [
|
||||
{ name: 'Up to date', count: totalHosts - hostsNeedingUpdates },
|
||||
{ name: 'Needs updates', count: hostsNeedingUpdates },
|
||||
{ name: 'Errored', count: erroredHosts }
|
||||
];
|
||||
// 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,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Package update priority distribution
|
||||
const packageUpdateDistribution = [
|
||||
{ name: 'Security', count: securityUpdates },
|
||||
{ name: 'Regular', count: totalOutdatedPackages - securityUpdates }
|
||||
];
|
||||
// Format OS distribution for pie chart
|
||||
const osDistributionFormatted = osDistribution.map((item) => ({
|
||||
name: item.os_type,
|
||||
count: item._count.os_type,
|
||||
}));
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
// 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 packageUpdateDistribution = [
|
||||
{ name: "Security", count: securityUpdates },
|
||||
{ name: "Regular", count: totalOutdatedPackages - securityUpdates },
|
||||
];
|
||||
|
||||
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.hosts.findMany({
|
||||
// Show all hosts regardless of status
|
||||
select: {
|
||||
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,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
host_packages: {
|
||||
where: {
|
||||
needs_update: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { last_update: '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.host_packages.count({
|
||||
where: {
|
||||
host_id: host.id,
|
||||
needs_update: 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 total packages count for this host
|
||||
const totalPackagesCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
host_id: host.id
|
||||
}
|
||||
});
|
||||
// Get total packages count for this host
|
||||
const totalPackagesCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
host_id: host.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Get the agent update interval setting for stale calculation
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const updateIntervalMinutes = settings?.update_interval || 60;
|
||||
const thresholdMinutes = updateIntervalMinutes * 2;
|
||||
// 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;
|
||||
|
||||
// Calculate effective status based on reporting interval
|
||||
const isStale = moment(host.last_update).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';
|
||||
}
|
||||
// Calculate effective status based on reporting interval
|
||||
const isStale = moment(host.last_update).isBefore(
|
||||
moment().subtract(thresholdMinutes, "minutes"),
|
||||
);
|
||||
let effectiveStatus = host.status;
|
||||
|
||||
return {
|
||||
...host,
|
||||
updatesCount,
|
||||
totalPackagesCount,
|
||||
isStale,
|
||||
effectiveStatus
|
||||
};
|
||||
})
|
||||
);
|
||||
// Override status if host hasn't reported within threshold
|
||||
if (isStale && host.status === "active") {
|
||||
effectiveStatus = "inactive";
|
||||
}
|
||||
|
||||
res.json(hostsWithUpdateInfo);
|
||||
} catch (error) {
|
||||
console.error('Error fetching hosts:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch hosts' });
|
||||
}
|
||||
return {
|
||||
...host,
|
||||
updatesCount,
|
||||
totalPackagesCount,
|
||||
isStale,
|
||||
effectiveStatus,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
res.json(hostsWithUpdateInfo);
|
||||
} catch (error) {
|
||||
console.error("Error fetching hosts:", error);
|
||||
res.status(500).json({ error: "Failed to fetch hosts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get packages that need updates across all hosts
|
||||
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'
|
||||
}
|
||||
});
|
||||
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.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
|
||||
}))
|
||||
}));
|
||||
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.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: 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 host = await 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: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
res.json(hostWithStats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching host details:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host details' });
|
||||
}
|
||||
});
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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
|
||||
});
|
||||
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' });
|
||||
}
|
||||
});
|
||||
res.json(hosts);
|
||||
} catch (error) {
|
||||
console.error("Error fetching recent collection:", error);
|
||||
res.status(500).json({ error: "Failed to fetch recent collection" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,230 +1,258 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { randomUUID } = require('crypto');
|
||||
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.host_groups.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.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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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.host_groups.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.host_groups.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
description: description || null,
|
||||
color: color || '#3B82F6',
|
||||
updated_at: new Date()
|
||||
}
|
||||
});
|
||||
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.host_groups.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.host_groups.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.host_groups.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
color: color || '#3B82F6',
|
||||
updated_at: new Date()
|
||||
}
|
||||
});
|
||||
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.host_groups.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.host_groups.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.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'
|
||||
}
|
||||
});
|
||||
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,222 +1,341 @@
|
||||
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 = "",
|
||||
} = 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 ? {
|
||||
host_packages: {
|
||||
some: {
|
||||
needs_update: needsUpdate === 'true'
|
||||
}
|
||||
}
|
||||
} : {},
|
||||
isSecurityUpdate ? {
|
||||
host_packages: {
|
||||
some: {
|
||||
is_security_update: 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 } } : {},
|
||||
// Update status filters
|
||||
needsUpdate
|
||||
? {
|
||||
host_packages: {
|
||||
some: {
|
||||
needs_update: needsUpdate === "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
isSecurityUpdate
|
||||
? {
|
||||
host_packages: {
|
||||
some: {
|
||||
is_security_update: isSecurityUpdate === "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
],
|
||||
};
|
||||
|
||||
// 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: {
|
||||
host_packages: true
|
||||
}
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
}),
|
||||
prisma.packages.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.host_packages.count({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
needs_update: true
|
||||
}
|
||||
}),
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
needs_update: true,
|
||||
is_security_update: true
|
||||
}
|
||||
}),
|
||||
prisma.host_packages.findMany({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
needs_update: true
|
||||
},
|
||||
select: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true
|
||||
}
|
||||
}
|
||||
},
|
||||
take: 10 // Limit to first 10 for performance
|
||||
})
|
||||
]);
|
||||
// Get additional stats for each package
|
||||
const packagesWithStats = await Promise.all(
|
||||
packages.map(async (pkg) => {
|
||||
const [updatesCount, securityCount, packageHosts] = await Promise.all([
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
needs_update: true,
|
||||
},
|
||||
}),
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
}),
|
||||
prisma.host_packages.findMany({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
needs_update: true,
|
||||
},
|
||||
select: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 10, // Limit to first 10 for performance
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
affectedHostsCount: pkg._count.hostPackages,
|
||||
affectedHosts: affectedHosts.map(hp => ({
|
||||
hostId: hp.host.id,
|
||||
friendlyName: hp.host.friendly_name,
|
||||
osType: hp.host.os_type,
|
||||
currentVersion: hp.current_version,
|
||||
availableVersion: hp.available_version,
|
||||
isSecurityUpdate: hp.is_security_update
|
||||
})),
|
||||
stats: {
|
||||
totalInstalls: pkg._count.hostPackages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
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),
|
||||
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' });
|
||||
}
|
||||
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.packages.findUnique({
|
||||
where: { id: packageId },
|
||||
include: {
|
||||
host_packages: {
|
||||
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.host_packages.length,
|
||||
updatesNeeded: packageData.host_packages.filter(hp => hp.needsUpdate).length,
|
||||
securityUpdates: packageData.host_packages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
|
||||
upToDate: packageData.host_packages.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.host_packages.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.host_packages.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,176 +1,203 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { requireManageSettings, requireManageUsers } = 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 (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'
|
||||
}
|
||||
});
|
||||
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.role_permissions.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 {
|
||||
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;
|
||||
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 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` });
|
||||
}
|
||||
// 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.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()
|
||||
}
|
||||
});
|
||||
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 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` });
|
||||
}
|
||||
// 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.users.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.role_permissions.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.role_permissions.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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,302 +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.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' }
|
||||
]
|
||||
});
|
||||
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.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
|
||||
}))
|
||||
}));
|
||||
// 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.host_repositories.findMany({
|
||||
where: { host_id: hostId },
|
||||
include: {
|
||||
repositories: true,
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
repositories: {
|
||||
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.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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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.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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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.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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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.hosts.friendly_name}`,
|
||||
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.repositories.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.host_repositories.aggregate({
|
||||
_count: {
|
||||
is_enabled: true
|
||||
},
|
||||
where: {
|
||||
is_enabled: true
|
||||
}
|
||||
});
|
||||
const hostRepoStats = await prisma.host_repositories.aggregate({
|
||||
_count: {
|
||||
is_enabled: true,
|
||||
},
|
||||
where: {
|
||||
is_enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const secureRepos = await prisma.repositories.count({
|
||||
where: { is_secure: true }
|
||||
});
|
||||
const secureRepos = await prisma.repositories.count({
|
||||
where: { is_secure: true },
|
||||
});
|
||||
|
||||
const activeRepos = await prisma.repositories.count({
|
||||
where: { is_active: 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.repositories.findMany({
|
||||
where: {
|
||||
host_repositories: {
|
||||
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.repositories.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,211 +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 { getSettings, updateSettings } = require('../services/settingsService');
|
||||
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...');
|
||||
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 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
|
||||
}
|
||||
});
|
||||
// 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`);
|
||||
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}`);
|
||||
// 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('http');
|
||||
const https = require('https');
|
||||
// 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 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 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 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}`);
|
||||
}
|
||||
});
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
const settings = await getSettings();
|
||||
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('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('signupEnabled').isBoolean().withMessage('Signup enabled must be a boolean'),
|
||||
body('defaultUserRole').optional().isLength({ min: 1 }).withMessage('Default user role must be a non-empty string'),
|
||||
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
|
||||
body('repositoryType').optional().isIn(['public', 'private']).withMessage('Repository type must be public or private'),
|
||||
body('sshKeyPath').optional().custom((value) => {
|
||||
if (value && value.trim().length === 0) {
|
||||
return true; // Allow empty string
|
||||
}
|
||||
if (value && value.trim().length < 1) {
|
||||
throw new Error('SSH key path must be a non-empty string');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
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, updateInterval, autoUpdate, signupEnabled, defaultUserRole, githubRepoUrl, repositoryType, sshKeyPath } = req.body;
|
||||
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;
|
||||
// Get current settings to check for update interval changes
|
||||
const currentSettings = await getSettings();
|
||||
const oldUpdateInterval = currentSettings.update_interval;
|
||||
|
||||
// Update settings using the service
|
||||
const updatedSettings = await updateSettings(currentSettings.id, {
|
||||
server_protocol: serverProtocol,
|
||||
server_host: serverHost,
|
||||
server_port: serverPort,
|
||||
update_interval: updateInterval || 60,
|
||||
auto_update: autoUpdate || false,
|
||||
signup_enabled: signupEnabled || false,
|
||||
default_user_role: defaultUserRole || process.env.DEFAULT_USER_ROLE || 'user',
|
||||
github_repo_url: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repository_type: repositoryType || 'public',
|
||||
ssh_key_path: sshKeyPath || null,
|
||||
});
|
||||
// Build update object with only provided fields
|
||||
const updateData = {};
|
||||
|
||||
console.log('Settings updated successfully:', updatedSettings);
|
||||
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;
|
||||
|
||||
// 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();
|
||||
}
|
||||
const updatedSettings = await updateSettings(
|
||||
currentSettings.id,
|
||||
updateData,
|
||||
);
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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 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' });
|
||||
}
|
||||
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 getSettings();
|
||||
res.json({
|
||||
updateInterval: settings.update_interval,
|
||||
cronExpression: `*/${settings.update_interval} * * * *` // 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 getSettings();
|
||||
res.json({
|
||||
autoUpdate: settings.auto_update || 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;
|
||||
|
||||
@@ -1,309 +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 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 }
|
||||
});
|
||||
router.get("/setup", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
if (user.tfa_enabled) {
|
||||
return res.status(400).json({
|
||||
error: 'Two-factor authentication is already enabled for this account'
|
||||
});
|
||||
}
|
||||
// Check if user already has TFA enabled
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: userId },
|
||||
select: { tfa_enabled: true, tfa_secret: true },
|
||||
});
|
||||
|
||||
// Generate a new secret
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `PatchMon (${req.user.username})`,
|
||||
issuer: 'PatchMon',
|
||||
length: 32
|
||||
});
|
||||
if (user.tfa_enabled) {
|
||||
return res.status(400).json({
|
||||
error: "Two-factor authentication is already enabled for this account",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate QR code
|
||||
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||
// Generate a new secret
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `PatchMon (${req.user.username})`,
|
||||
issuer: "PatchMon",
|
||||
length: 32,
|
||||
});
|
||||
|
||||
// Store the secret temporarily (not enabled yet)
|
||||
await prisma.users.update({
|
||||
where: { id: userId },
|
||||
data: { tfa_secret: secret.base32 }
|
||||
});
|
||||
// Generate QR code
|
||||
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||
|
||||
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' });
|
||||
}
|
||||
// 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() });
|
||||
}
|
||||
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;
|
||||
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 }
|
||||
});
|
||||
// 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_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'
|
||||
});
|
||||
}
|
||||
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
|
||||
});
|
||||
// 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.'
|
||||
});
|
||||
}
|
||||
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()
|
||||
);
|
||||
// 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)
|
||||
}
|
||||
});
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
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() });
|
||||
}
|
||||
router.post(
|
||||
"/disable",
|
||||
authenticateToken,
|
||||
[
|
||||
body("password")
|
||||
.notEmpty()
|
||||
.withMessage("Password is required to disable TFA"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { password } = req.body;
|
||||
const userId = req.user.id;
|
||||
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 }
|
||||
});
|
||||
// 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'
|
||||
});
|
||||
}
|
||||
if (!user.tfa_enabled) {
|
||||
return res.status(400).json({
|
||||
error: "Two-factor authentication is not enabled for this account",
|
||||
});
|
||||
}
|
||||
|
||||
// Note: In a real implementation, you would verify the password hash here
|
||||
// For now, we'll skip password verification for simplicity
|
||||
// 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: id },
|
||||
data: {
|
||||
tfa_enabled: false,
|
||||
tfa_secret: null,
|
||||
tfa_backup_codes: null
|
||||
}
|
||||
});
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
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
|
||||
}
|
||||
});
|
||||
router.get("/status", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
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' });
|
||||
}
|
||||
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;
|
||||
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 }
|
||||
});
|
||||
// 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'
|
||||
});
|
||||
}
|
||||
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()
|
||||
);
|
||||
// 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)
|
||||
}
|
||||
});
|
||||
// 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' });
|
||||
}
|
||||
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() });
|
||||
}
|
||||
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;
|
||||
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
|
||||
}
|
||||
});
|
||||
// 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'
|
||||
});
|
||||
}
|
||||
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);
|
||||
// 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;
|
||||
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 (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'
|
||||
});
|
||||
}
|
||||
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' });
|
||||
}
|
||||
});
|
||||
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,204 +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 {
|
||||
// Read version from package.json dynamically
|
||||
let currentVersion = '1.2.6'; // fallback
|
||||
|
||||
try {
|
||||
const packageJson = require('../../package.json');
|
||||
if (packageJson && packageJson.version) {
|
||||
currentVersion = packageJson.version;
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn('Could not read version from package.json, using fallback:', packageError.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
version: currentVersion,
|
||||
buildDate: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting current version:', error);
|
||||
res.status(500).json({ error: 'Failed to get current version' });
|
||||
}
|
||||
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 cached update information from settings
|
||||
const settings = await prisma.settings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return res.status(400).json({ error: 'Settings not found' });
|
||||
}
|
||||
router.get(
|
||||
"/check-updates",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
// Get cached update information from settings
|
||||
const settings = await prisma.settings.findFirst();
|
||||
|
||||
const currentVersion = '1.2.6';
|
||||
const latestVersion = settings.latest_version || currentVersion;
|
||||
const isUpdateAvailable = settings.update_available || false;
|
||||
const lastUpdateCheck = settings.last_update_check || null;
|
||||
if (!settings) {
|
||||
return res.status(400).json({ error: "Settings not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpdateAvailable,
|
||||
lastUpdateCheck,
|
||||
repositoryType: settings.repository_type || 'public',
|
||||
latestRelease: {
|
||||
tagName: latestVersion ? `v${latestVersion}` : null,
|
||||
version: latestVersion,
|
||||
repository: settings.github_repo_url ? settings.github_repo_url.split('/').slice(-2).join('/') : null,
|
||||
accessMethod: settings.repository_type === 'private' ? 'ssh' : 'api'
|
||||
}
|
||||
});
|
||||
const currentVersion = getCurrentVersion();
|
||||
const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting update information:', error);
|
||||
res.status(500).json({ error: 'Failed to get update information' });
|
||||
}
|
||||
});
|
||||
let latestRelease = null;
|
||||
let latestCommit = null;
|
||||
let commitDifference = null;
|
||||
|
||||
// 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;
|
||||
}
|
||||
// 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),
|
||||
]);
|
||||
|
||||
latestRelease = releaseData;
|
||||
latestCommit = commitData;
|
||||
commitDifference = differenceData;
|
||||
} catch (githubError) {
|
||||
console.warn(
|
||||
"Failed to fetch fresh GitHub data:",
|
||||
githubError.message,
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersion =
|
||||
latestRelease?.version || settings.latest_version || currentVersion;
|
||||
const isUpdateAvailable = latestRelease
|
||||
? compareVersions(latestVersion, currentVersion) > 0
|
||||
: settings.update_available || false;
|
||||
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -8,176 +8,191 @@ 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',
|
||||
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';
|
||||
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();
|
||||
}
|
||||
// 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();
|
||||
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 protocol = process.env.SERVER_PROTOCOL || "http";
|
||||
const host = process.env.SERVER_HOST || "localhost";
|
||||
const port = parseInt(process.env.SERVER_PORT, 10) || 3001;
|
||||
const serverUrl = constructServerUrl(protocol, host, port);
|
||||
|
||||
const settings = await prisma.settings.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
server_url: serverUrl,
|
||||
server_protocol: protocol,
|
||||
server_host: host,
|
||||
server_port: port,
|
||||
update_interval: 60,
|
||||
auto_update: false,
|
||||
signup_enabled: false,
|
||||
updated_at: new Date()
|
||||
}
|
||||
});
|
||||
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;
|
||||
console.log("Created settings");
|
||||
return settings;
|
||||
}
|
||||
|
||||
// Sync environment variables with existing settings
|
||||
async function syncEnvironmentToSettings(currentSettings) {
|
||||
const updates = {};
|
||||
let hasChanges = false;
|
||||
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];
|
||||
// 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);
|
||||
}
|
||||
// Convert environment value to appropriate type
|
||||
let convertedValue = envValue;
|
||||
if (settingsField === "server_port") {
|
||||
convertedValue = parseInt(envValue, 10);
|
||||
}
|
||||
|
||||
// Only update if values differ
|
||||
if (currentValue !== convertedValue) {
|
||||
updates[settingsField] = convertedValue;
|
||||
hasChanges = true;
|
||||
console.log(`Environment variable ${envVar} (${envValue}) differs from settings ${settingsField} (${currentValue}), updating...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
// Construct server_url from components if any components were updated
|
||||
const protocol = updates.server_protocol || currentSettings.server_protocol;
|
||||
const host = updates.server_host || currentSettings.server_host;
|
||||
const port = updates.server_port || currentSettings.server_port;
|
||||
const constructedServerUrl = constructServerUrl(protocol, host, port);
|
||||
|
||||
// Update server_url if it differs from the constructed value
|
||||
if (currentSettings.server_url !== constructedServerUrl) {
|
||||
updates.server_url = constructedServerUrl;
|
||||
hasChanges = true;
|
||||
console.log(`Updating server_url to: ${constructedServerUrl}`);
|
||||
}
|
||||
// Update 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()
|
||||
}
|
||||
});
|
||||
console.log(`Synced ${Object.keys(updates).length} environment variables to settings`);
|
||||
return updatedSettings;
|
||||
}
|
||||
// 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;
|
||||
return currentSettings;
|
||||
}
|
||||
|
||||
// Initialise settings - create from environment or sync existing
|
||||
async function initSettings() {
|
||||
if (cachedSettings) {
|
||||
return cachedSettings;
|
||||
}
|
||||
if (cachedSettings) {
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
try {
|
||||
let settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: 'desc' }
|
||||
});
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
// 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();
|
||||
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()
|
||||
}
|
||||
});
|
||||
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 }
|
||||
});
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
// 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;
|
||||
cachedSettings = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initSettings,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
invalidateCache,
|
||||
syncEnvironmentToSettings // Export for startup use
|
||||
initSettings,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
invalidateCache,
|
||||
syncEnvironmentToSettings, // Export for startup use
|
||||
};
|
||||
|
||||
@@ -1,264 +1,292 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
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
|
||||
}
|
||||
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;
|
||||
}
|
||||
// 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`);
|
||||
}
|
||||
console.log("🔄 Starting update scheduler...");
|
||||
this.isRunning = true;
|
||||
|
||||
// Stop the scheduler
|
||||
stop() {
|
||||
if (!this.isRunning) {
|
||||
console.log('Update scheduler is not running');
|
||||
return;
|
||||
}
|
||||
// Run initial check
|
||||
this.checkForUpdates();
|
||||
|
||||
console.log('🛑 Stopping update scheduler...');
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
console.log('✅ Update scheduler stopped');
|
||||
}
|
||||
// Schedule regular checks
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkForUpdates();
|
||||
}, this.checkInterval);
|
||||
|
||||
// Check for updates
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
console.log('🔍 Checking for updates...');
|
||||
|
||||
// Get settings
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (!settings || !settings.githubRepoUrl) {
|
||||
console.log('⚠️ No GitHub repository configured, skipping update check');
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`✅ Update scheduler started - checking every ${this.checkInterval / (60 * 60 * 1000)} hours`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract owner and repo from GitHub URL
|
||||
const repoUrl = settings.githubRepoUrl;
|
||||
let owner, repo;
|
||||
|
||||
if (repoUrl.includes('git@github.com:')) {
|
||||
const match = repoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
} else if (repoUrl.includes('github.com/')) {
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
}
|
||||
// Stop the scheduler
|
||||
stop() {
|
||||
if (!this.isRunning) {
|
||||
console.log("Update scheduler is not running");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!owner || !repo) {
|
||||
console.log('⚠️ Could not parse GitHub repository URL, skipping update check');
|
||||
return;
|
||||
}
|
||||
console.log("🛑 Stopping update scheduler...");
|
||||
this.isRunning = false;
|
||||
|
||||
let latestVersion;
|
||||
const isPrivate = settings.repositoryType === 'private';
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
console.log("✅ Update scheduler stopped");
|
||||
}
|
||||
|
||||
if (!latestVersion) {
|
||||
console.log('⚠️ Could not determine latest version, skipping update check');
|
||||
return;
|
||||
}
|
||||
// Check for updates
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
console.log("🔍 Checking for updates...");
|
||||
|
||||
// Read version from package.json dynamically
|
||||
let currentVersion = '1.2.6'; // fallback
|
||||
try {
|
||||
const packageJson = require('../../package.json');
|
||||
if (packageJson && packageJson.version) {
|
||||
currentVersion = packageJson.version;
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn('Could not read version from package.json, using fallback:', packageError.message);
|
||||
}
|
||||
const isUpdateAvailable = this.compareVersions(latestVersion, currentVersion) > 0;
|
||||
// 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;
|
||||
|
||||
// Update settings with check results
|
||||
await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
lastUpdateCheck: new Date(),
|
||||
updateAvailable: isUpdateAvailable,
|
||||
latestVersion: latestVersion
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`);
|
||||
if (!owner || !repo) {
|
||||
console.log(
|
||||
"⚠️ Could not parse GitHub repository URL, skipping update check",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking for updates:', error.message);
|
||||
|
||||
// Update last check time even on error
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings) {
|
||||
await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
lastUpdateCheck: new Date(),
|
||||
updateAvailable: false
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (updateError) {
|
||||
console.error('❌ Error updating last check time:', updateError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
let latestVersion;
|
||||
const isPrivate = settings.repositoryType === "private";
|
||||
|
||||
// Check private repository using SSH
|
||||
async checkPrivateRepo(settings, owner, repo) {
|
||||
try {
|
||||
let sshKeyPath = settings.sshKeyPath;
|
||||
|
||||
// Try to find SSH key if not configured
|
||||
if (!sshKeyPath) {
|
||||
const possibleKeyPaths = [
|
||||
'/root/.ssh/id_ed25519',
|
||||
'/root/.ssh/id_rsa',
|
||||
'/home/patchmon/.ssh/id_ed25519',
|
||||
'/home/patchmon/.ssh/id_rsa',
|
||||
'/var/www/.ssh/id_ed25519',
|
||||
'/var/www/.ssh/id_rsa'
|
||||
];
|
||||
|
||||
for (const path of possibleKeyPaths) {
|
||||
try {
|
||||
require('fs').accessSync(path);
|
||||
sshKeyPath = path;
|
||||
break;
|
||||
} catch (e) {
|
||||
// Key not found at this path, try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sshKeyPath) {
|
||||
throw new Error('No SSH deploy key found');
|
||||
}
|
||||
|
||||
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!latestVersion) {
|
||||
console.log(
|
||||
"⚠️ Could not determine latest version, skipping update check",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check public repository using GitHub API
|
||||
async checkPublicRepo(owner, repo) {
|
||||
try {
|
||||
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
|
||||
// Get current version for User-Agent
|
||||
let currentVersion = '1.2.6'; // fallback
|
||||
try {
|
||||
const packageJson = require('../../package.json');
|
||||
if (packageJson && packageJson.version) {
|
||||
currentVersion = packageJson.version;
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn('Could not read version from package.json for User-Agent, using fallback:', packageError.message);
|
||||
}
|
||||
// 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;
|
||||
|
||||
const response = await fetch(httpsRepoUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': `PatchMon-Server/${currentVersion}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const releaseData = await response.json();
|
||||
return releaseData.tag_name.replace('v', '');
|
||||
} catch (error) {
|
||||
console.error('GitHub API error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
console.log(
|
||||
`✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ Error checking for updates:", error.message);
|
||||
|
||||
// Get scheduler status
|
||||
getStatus() {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
checkInterval: this.checkInterval,
|
||||
nextCheck: this.isRunning ? new Date(Date.now() + this.checkInterval) : null
|
||||
};
|
||||
}
|
||||
// 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
|
||||
|
||||
322
backend/src/utils/session_manager.js
Normal file
322
backend/src/utils/session_manager.js
Normal file
@@ -0,0 +1,322 @@
|
||||
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 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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for user
|
||||
*/
|
||||
async function create_session(user_id, ip_address, user_agent) {
|
||||
try {
|
||||
const session_id = crypto.randomUUID();
|
||||
const refresh_token = generate_refresh_token();
|
||||
const access_token = generate_access_token(user_id, session_id);
|
||||
|
||||
const expires_at = parse_expiration(JWT_REFRESH_EXPIRES_IN);
|
||||
|
||||
// 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,
|
||||
last_activity: new Date(),
|
||||
expires_at: expires_at,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
session_id,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at,
|
||||
};
|
||||
} 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,
|
||||
},
|
||||
orderBy: { last_activity: "desc" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error getting user sessions:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create_session,
|
||||
validate_session,
|
||||
update_session_activity,
|
||||
refresh_access_token,
|
||||
revoke_session,
|
||||
revoke_all_user_sessions,
|
||||
cleanup_expired_sessions,
|
||||
get_user_sessions,
|
||||
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 |
272
docker/README.md
272
docker/README.md
@@ -6,29 +6,92 @@ PatchMon is a containerised application that monitors system patches and updates
|
||||
|
||||
- **Database**: PostgreSQL 17
|
||||
- **Backend**: Node.js API server
|
||||
- **Frontend**: React application served via Nginx
|
||||
- **Frontend**: React application served via NGINX
|
||||
|
||||
## Images
|
||||
|
||||
- **Backend**: `ghcr.io/9technologygroup/patchmon-backend:latest`
|
||||
- **Frontend**: `ghcr.io/9technologygroup/patchmon-frontend:latest`
|
||||
- **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)
|
||||
|
||||
Version tags are also available (e.g. `1.2.3`) for both of these images.
|
||||
### 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.
|
||||
|
||||
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. Configure environment variables (see [Configuration](#configuration) section)
|
||||
|
||||
3. Start the application:
|
||||
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`
|
||||
|
||||
4. 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
|
||||
|
||||
@@ -36,47 +99,80 @@ Version tags are also available (e.g. `1.2.3`) for both of these images.
|
||||
|
||||
#### Database Service
|
||||
|
||||
- `POSTGRES_DB`: Database name (default: `patchmon_db`)
|
||||
- `POSTGRES_USER`: Database user (default: `patchmon_user`)
|
||||
- `POSTGRES_PASSWORD`: Database password - **MUST BE CHANGED!**
|
||||
| Variable | Description | Default |
|
||||
| ------------------- | ----------------- | ---------------- |
|
||||
| `POSTGRES_DB` | Database name | `patchmon_db` |
|
||||
| `POSTGRES_USER` | Database user | `patchmon_user` |
|
||||
| `POSTGRES_PASSWORD` | Database password | **MUST BE SET!** |
|
||||
|
||||
#### Backend Service
|
||||
|
||||
- `LOG_LEVEL`: Logging level (`debug`, `info`, `warn`, `error`)
|
||||
- `DATABASE_URL`: PostgreSQL connection string
|
||||
- `PM_DB_CONN_MAX_ATTEMPTS`: Maximum database connection attempts (default: 30)
|
||||
- `PM_DB_CONN_WAIT_INTERVAL`: Wait interval between connection attempts in seconds (default: 2)
|
||||
- `SERVER_PROTOCOL`: Frontend server protocol (`http` or `https`)
|
||||
- `SERVER_HOST`: Frontend server host (default: `localhost`)
|
||||
- `SERVER_PORT`: Frontend server port (default: 3000)
|
||||
- `PORT`: Backend API port (default: 3001)
|
||||
- `API_VERSION`: API version (default: `v1`)
|
||||
- `CORS_ORIGIN`: CORS origin URL
|
||||
- `RATE_LIMIT_WINDOW_MS`: Rate limiting window in milliseconds (default: 900000)
|
||||
- `RATE_LIMIT_MAX`: Maximum requests per window (default: 100)
|
||||
- `ENABLE_HSTS`: Enable HTTP Strict Transport Security (default: true)
|
||||
- `TRUST_PROXY`: Trust proxy headers (default: true) - See [Express.js docs](https://expressjs.com/en/guide/behind-proxies.html) for usage.
|
||||
##### 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
|
||||
|
||||
- `BACKEND_HOST`: Backend service hostname (default: `backend`)
|
||||
- `BACKEND_PORT`: Backend service port (default: 3001)
|
||||
| Variable | Description | Default |
|
||||
| -------------- | ------------------------ | --------- |
|
||||
| `BACKEND_HOST` | Backend service hostname | `backend` |
|
||||
| `BACKEND_PORT` | Backend service port | `3001` |
|
||||
|
||||
### Security Configuration
|
||||
### Volumes
|
||||
|
||||
**⚠️ IMPORTANT**: Before deploying to production, you MUST:
|
||||
The compose file creates two Docker volumes:
|
||||
|
||||
1. Change the default database password in `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
POSTGRES_PASSWORD: YOUR_SECURE_PASSWORD_HERE
|
||||
```
|
||||
* `postgres_data`: PostgreSQL's data directory.
|
||||
* `agent_files`: PatchMon's agent files.
|
||||
|
||||
2. Update the corresponding `DATABASE_URL` in the backend service:
|
||||
```yaml
|
||||
environment:
|
||||
DATABASE_URL: postgresql://patchmon_user:YOUR_SECURE_PASSWORD_HERE@database:5432/patchmon_db
|
||||
```
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -90,47 +186,61 @@ For development with live reload and source code mounting:
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/9technologygroup/patchmon.net.git
|
||||
git clone https://github.com/PatchMon/PatchMon.git
|
||||
cd patchmon.net
|
||||
```
|
||||
|
||||
2. Start development environment:
|
||||
```bash
|
||||
# Attached, live log output, services stopped on Ctrl+C
|
||||
docker compose -f docker/docker-compose.dev.yml up
|
||||
|
||||
# Detached
|
||||
docker compose -f docker/docker-compose.dev.yml up -d
|
||||
```
|
||||
_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
|
||||
- Enables development workflow
|
||||
- Supports live reload and debugging
|
||||
- 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
|
||||
|
||||
```
|
||||
docker build -t patchmon-backend:dev -f docker/backend.Dockerfile .
|
||||
docker build -t patchmon-frontend:dev -f docker/frontend.Dockerfile .
|
||||
```
|
||||
|
||||
## Running in Docker Compose
|
||||
|
||||
For development or custom builds:
|
||||
Both Dockerfiles use multi-stage builds with separate development and production targets:
|
||||
|
||||
```bash
|
||||
# Build backend image
|
||||
docker build -f docker/backend.Dockerfile -t patchmon-backend:dev .
|
||||
# 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 frontend image
|
||||
docker build -f docker/frontend.Dockerfile -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
|
||||
@@ -140,9 +250,43 @@ docker compose -f docker/docker-compose.dev.yml up -d --build backend
|
||||
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. **Code Changes**: Edit source files
|
||||
2. **Rebuild**: `docker compose -f docker/docker-compose.dev.yml up -d --build`
|
||||
3. **Test**: Access application and verify changes
|
||||
4. **Debug**: Check logs with `docker compose logs -f`
|
||||
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
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
FROM node:lts-alpine AS builder
|
||||
# Development target
|
||||
FROM node:lts-alpine AS development
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=node:node package*.json /app/
|
||||
COPY --chown=node:node backend/ /app/backend/
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
RUN npm ci &&\
|
||||
npx prisma generate &&\
|
||||
npm prune --omit=dev &&\
|
||||
npm cache clean --force
|
||||
|
||||
FROM node:lts-alpine
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
ENV NODE_ENV=development \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
||||
ENABLE_LOGGING=true \
|
||||
LOG_LEVEL=info \
|
||||
PM_LOG_TO_CONSOLE=true \
|
||||
@@ -28,8 +14,64 @@ USER node
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/backend /app/backend
|
||||
COPY --from=builder /app/node_modules /app/node_modules
|
||||
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
|
||||
|
||||
@@ -20,10 +20,14 @@ else
|
||||
log "Agents directory already contains files, skipping copy"
|
||||
fi
|
||||
|
||||
log "Starting PatchMon Backend..."
|
||||
log "Starting PatchMon Backend (${NODE_ENV:-production})..."
|
||||
|
||||
log "Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
log "Starting application..."
|
||||
exec npm start
|
||||
if [ "${NODE_ENV}" = "development" ]; then
|
||||
exec npm run dev
|
||||
else
|
||||
exec npm start
|
||||
fi
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: patchmon-dev
|
||||
|
||||
services:
|
||||
database:
|
||||
image: postgres:17-alpine
|
||||
@@ -5,41 +7,59 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: patchmon_db
|
||||
POSTGRES_USER: patchmon_user
|
||||
POSTGRES_PASSWORD: INSECURE_REPLACE_ME_PLEASE_INSECURE
|
||||
POSTGRES_PASSWORD: 1NS3CU6E_DEV_D8_PASSW0RD
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./compose_dev_data/db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
|
||||
interval: 10s
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
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:INSECURE_REPLACE_ME_PLEASE_INSECURE@database:5432/patchmon_db
|
||||
PM_DB_CONN_MAX_ATTEMPTS: 30
|
||||
PM_DB_CONN_WAIT_INTERVAL: 2
|
||||
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
|
||||
RATE_LIMIT_WINDOW_MS: 900000
|
||||
RATE_LIMIT_MAX: 100
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./agents:/app/agents
|
||||
- ./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
|
||||
@@ -49,6 +69,12 @@ services:
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
path: ../frontend/src
|
||||
target: /app/frontend/src
|
||||
ignore:
|
||||
- node_modules/
|
||||
- action: rebuild
|
||||
path: ../frontend/package.json
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
name: patchmon
|
||||
|
||||
services:
|
||||
database:
|
||||
image: postgres:17-alpine3.22
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: patchmon_db
|
||||
POSTGRES_USER: patchmon_user
|
||||
POSTGRES_PASSWORD: INSECURE_REPLACE_ME_PLEASE_INSECURE
|
||||
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: 10s
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 7
|
||||
|
||||
backend:
|
||||
image: ghcr.io/9technologygroup/patchmon-backend:latest
|
||||
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:INSECURE_REPLACE_ME_PLEASE_INSECURE@database:5432/patchmon_db
|
||||
PM_DB_CONN_MAX_ATTEMPTS: 30
|
||||
PM_DB_CONN_WAIT_INTERVAL: 2
|
||||
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
|
||||
RATE_LIMIT_WINDOW_MS: 900000
|
||||
RATE_LIMIT_MAX: 100
|
||||
volumes:
|
||||
- ./agents:/app/agents
|
||||
- agent_files:/app/agents
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/9technologygroup/patchmon-frontend:latest
|
||||
image: ghcr.io/patchmon/patchmon-frontend:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -45,3 +45,4 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
agent_files:
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
# 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
|
||||
@@ -5,12 +22,13 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY frontend/package*.json ./frontend/
|
||||
|
||||
RUN npm ci
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
RUN npm run build:frontend
|
||||
|
||||
# Production stage
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
ENV BACKEND_HOST=backend \
|
||||
|
||||
@@ -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,47 +1,44 @@
|
||||
{
|
||||
"name": "patchmon-frontend",
|
||||
"private": true,
|
||||
"version": "1.2.6",
|
||||
"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",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^2.30.0",
|
||||
"express": "^4.18.2",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.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.25.10"
|
||||
}
|
||||
"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 |
@@ -1,45 +1,50 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import cors from 'cors';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
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';
|
||||
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
|
||||
}));
|
||||
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}`);
|
||||
}
|
||||
}));
|
||||
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')));
|
||||
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.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')}`);
|
||||
console.log(`Frontend server running on port ${PORT}`);
|
||||
console.log(`Serving from: ${path.join(__dirname, "dist")}`);
|
||||
});
|
||||
|
||||
@@ -1,147 +1,352 @@
|
||||
import React from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext'
|
||||
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 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 Options from './pages/Options'
|
||||
import Profile from './pages/Profile'
|
||||
import HostDetail from './pages/HostDetail'
|
||||
import PackageDetail from './pages/PackageDetail'
|
||||
import FirstTimeAdminSetup from './components/FirstTimeAdminSetup'
|
||||
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";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import HostDetail from "./pages/HostDetail";
|
||||
import Hosts from "./pages/Hosts";
|
||||
import Login from "./pages/Login";
|
||||
import PackageDetail from "./pages/PackageDetail";
|
||||
import Packages from "./pages/Packages";
|
||||
import Profile from "./pages/Profile";
|
||||
import Repositories from "./pages/Repositories";
|
||||
import RepositoryDetail from "./pages/RepositoryDetail";
|
||||
import AlertChannels from "./pages/settings/AlertChannels";
|
||||
import Integrations from "./pages/settings/Integrations";
|
||||
import Notifications from "./pages/settings/Notifications";
|
||||
import PatchManagement from "./pages/settings/PatchManagement";
|
||||
import SettingsAgentConfig from "./pages/settings/SettingsAgentConfig";
|
||||
import SettingsHostGroups from "./pages/settings/SettingsHostGroups";
|
||||
import SettingsServerConfig from "./pages/settings/SettingsServerConfig";
|
||||
import SettingsUsers from "./pages/settings/SettingsUsers";
|
||||
|
||||
function AppRoutes() {
|
||||
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth()
|
||||
const isAuth = isAuthenticated() // Call the function to get boolean value
|
||||
const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
|
||||
const isAuth = isAuthenticated(); // Call the function to get boolean value
|
||||
|
||||
// Show loading while checking if setup is needed
|
||||
if (checkingSetup) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">Checking system status...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Show 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 />
|
||||
}
|
||||
// Show first-time setup if no admin users exist
|
||||
if (needsFirstTimeSetup && !isAuth) {
|
||||
return <FirstTimeAdminSetup />;
|
||||
}
|
||||
|
||||
return (
|
||||
<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="/users" element={
|
||||
<ProtectedRoute requirePermission="can_view_users">
|
||||
<Layout>
|
||||
<Users />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/permissions" element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<Permissions />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/options" element={
|
||||
<ProtectedRoute requirePermission="can_manage_hosts">
|
||||
<Layout>
|
||||
<Options />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Profile />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/packages/:packageId" element={
|
||||
<ProtectedRoute requirePermission="can_view_packages">
|
||||
<Layout>
|
||||
<PackageDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
)
|
||||
return (
|
||||
<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="/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>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<AppRoutes />
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -1,336 +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';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
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 { isDark } = useTheme();
|
||||
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 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
|
||||
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>
|
||||
);
|
||||
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 { isDark } = useTheme();
|
||||
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) {
|
||||
// Normalize server preferences (snake_case -> camelCase)
|
||||
const normalizedPreferences = preferences.map((p) => ({
|
||||
cardId: p.cardId ?? p.card_id,
|
||||
enabled: p.enabled,
|
||||
order: p.order,
|
||||
}));
|
||||
// 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 typeLabelFor = (cardId) => {
|
||||
if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card';
|
||||
if (cardId === 'osDistribution') return 'Pie chart';
|
||||
if (cardId === 'osDistributionBar') return 'Bar chart';
|
||||
if (cardId === 'updateStatus') return 'Pie chart';
|
||||
if (cardId === 'packagePriority') return 'Pie chart';
|
||||
if (cardId === 'recentUsers') return 'Table';
|
||||
if (cardId === 'recentCollection') return 'Table';
|
||||
if (cardId === 'quickStats') return 'Wide card';
|
||||
return undefined;
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
setCards(mergedCards);
|
||||
}
|
||||
}, [preferences, defaultCards]);
|
||||
// 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 handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
setCards(mergedCards);
|
||||
}
|
||||
}, [preferences, defaultCards]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
const handleToggle = (cardId) => {
|
||||
setCards(prevCards =>
|
||||
prevCards.map(card =>
|
||||
card.cardId === cardId
|
||||
? { ...card, enabled: !card.enabled }
|
||||
: card
|
||||
)
|
||||
);
|
||||
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);
|
||||
|
||||
const handleSave = () => {
|
||||
const preferences = cards.map(card => ({
|
||||
cardId: card.cardId,
|
||||
enabled: card.enabled,
|
||||
order: card.order
|
||||
}));
|
||||
|
||||
updatePreferencesMutation.mutate(preferences);
|
||||
};
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
const handleReset = () => {
|
||||
if (defaultCards) {
|
||||
const resetCards = defaultCards.map(card => ({
|
||||
...card,
|
||||
enabled: true,
|
||||
order: card.order
|
||||
}));
|
||||
setCards(resetCards);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
// Update order values
|
||||
return newItems.map((item, index) => ({
|
||||
...item,
|
||||
order: index,
|
||||
}));
|
||||
});
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
const handleToggle = (cardId) => {
|
||||
setCards((prevCards) =>
|
||||
prevCards.map((card) =>
|
||||
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
|
||||
),
|
||||
);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
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 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
|
||||
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>
|
||||
const handleSave = () => {
|
||||
const preferences = cards.map((card) => ({
|
||||
cardId: card.cardId,
|
||||
enabled: card.enabled,
|
||||
order: card.order,
|
||||
}));
|
||||
|
||||
{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
|
||||
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 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
|
||||
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>
|
||||
);
|
||||
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;
|
||||
@@ -1,297 +1,348 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { UserPlus, Shield, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
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 } = useAuth()
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
firstName: '',
|
||||
lastName: ''
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const { 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 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 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;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) return
|
||||
// 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;
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
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;
|
||||
};
|
||||
|
||||
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 handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const data = await response.json()
|
||||
if (!validateForm()) return;
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true)
|
||||
// Auto-login the user after successful setup
|
||||
setTimeout(() => {
|
||||
login(formData.username.trim(), formData.password)
|
||||
}, 2000)
|
||||
} else {
|
||||
setError(data.error || 'Failed to create admin user')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error)
|
||||
setError('Network error. Please try again.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="card p-8 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-full">
|
||||
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-4">
|
||||
Admin Account Created!
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Your admin account has been successfully created. You will be automatically logged in shortly.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
|
||||
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>
|
||||
const data = await response.json();
|
||||
|
||||
{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>
|
||||
)}
|
||||
if (response.ok) {
|
||||
setSuccess(true);
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className="input w-full"
|
||||
placeholder="Enter your first name"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
className="input w-full"
|
||||
placeholder="Enter your last name"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="input w-full"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="input w-full"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="input w-full"
|
||||
placeholder="Enter your password (min 8 characters)"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className="input w-full"
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
<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>
|
||||
|
||||
export default FirstTimeAdminSetup
|
||||
<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;
|
||||
@@ -1,157 +1,162 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Edit2, Check, X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 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);
|
||||
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(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditValue(value);
|
||||
}, [value]);
|
||||
useEffect(() => {
|
||||
setEditValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setEditValue(value);
|
||||
setError('');
|
||||
};
|
||||
const handleEdit = () => {
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setEditValue(value);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditValue(value);
|
||||
setError('');
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditValue(value);
|
||||
setError("");
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
const handleSave = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
|
||||
// Validate if validator function provided
|
||||
if (validate) {
|
||||
const validationError = validate(editValue);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
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;
|
||||
}
|
||||
// Check if value actually changed
|
||||
if (editValue.trim() === value.trim()) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await onSave(editValue.trim());
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
|
||||
error ? 'border-red-500' : ''
|
||||
} ${isLoading ? 'opacity-50' : ''}`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || editValue.trim() === ''}
|
||||
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
const displayValue = linkTo ? (
|
||||
<Link
|
||||
to={linkTo}
|
||||
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
|
||||
title="View details"
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 group ${className}`}>
|
||||
{displayValue}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -1,257 +1,272 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
|
||||
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,
|
||||
placeholder = "Select group..."
|
||||
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);
|
||||
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(() => {
|
||||
if (isEditing && dropdownRef.current) {
|
||||
dropdownRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedValue(value);
|
||||
// Force re-render when value changes
|
||||
if (!isEditing) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [value, isEditing]);
|
||||
useEffect(() => {
|
||||
setSelectedValue(value);
|
||||
// Force re-render when value changes
|
||||
if (!isEditing) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [value, isEditing]);
|
||||
|
||||
// Calculate dropdown position
|
||||
const calculateDropdownPosition = () => {
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
width: rect.width
|
||||
});
|
||||
}
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
// 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]);
|
||||
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 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 handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setSelectedValue(value);
|
||||
setError("");
|
||||
setIsOpen(false);
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
const handleSave = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
|
||||
// Check if value actually changed
|
||||
if (selectedValue === value) {
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
// Check if value actually changed
|
||||
if (selectedValue === value) {
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
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);
|
||||
}
|
||||
};
|
||||
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 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 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 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]);
|
||||
const selectedOption = useMemo(() => {
|
||||
return options.find((opt) => opt.id === value);
|
||||
}, [value, options]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
|
||||
error ? 'border-red-500' : ''
|
||||
} ${isLoading ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedValue ? options.find(opt => opt.id === selectedValue)?.name || 'Unknown Group' : 'Ungrouped'}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
minWidth: '200px'
|
||||
}}
|
||||
>
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedValue(null);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
|
||||
selectedValue === null ? 'bg-primary-50 dark:bg-primary-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
|
||||
Ungrouped
|
||||
</span>
|
||||
</button>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedValue(option.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
|
||||
selectedValue === option.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: option.color }}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 group ${className}`}>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
|
||||
style={value ? { backgroundColor: selectedOption?.color } : {}}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{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 it is too large
Load Diff
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;
|
||||
37
frontend/src/components/LogoProvider.jsx
Normal file
37
frontend/src/components/LogoProvider.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { settingsAPI } from "../utils/api";
|
||||
|
||||
const LogoProvider = ({ children }) => {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
comingSoon: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -1,15 +1,14 @@
|
||||
import React from 'react'
|
||||
import { ArrowUpCircle } from 'lucide-react'
|
||||
import { ArrowUpCircle } from "lucide-react";
|
||||
|
||||
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
|
||||
if (!show) return null
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<ArrowUpCircle
|
||||
className={`${className} text-red-500 animate-pulse`}
|
||||
title="Update available"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ArrowUpCircle
|
||||
className={`${className} text-red-500 animate-pulse`}
|
||||
title="Update available"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeNotificationIcon
|
||||
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;
|
||||
979
frontend/src/components/settings/UsersTab.jsx
Normal file
979
frontend/src/components/settings/UsersTab.jsx
Normal file
@@ -0,0 +1,979 @@
|
||||
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)}
|
||||
onUserUpdated={() => {
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
}}
|
||||
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, onUserUpdated, 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 [isLoading, setIsLoading] = useState(false);
|
||||
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();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
await adminUsersAPI.update(user.id, formData);
|
||||
setSuccess(true);
|
||||
onUserUpdated();
|
||||
// Auto-close after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || "Failed to update user");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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,298 +1,315 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } 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 [permissionsLoading, setPermissionsLoading] = useState(false)
|
||||
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false)
|
||||
|
||||
const [checkingSetup, setCheckingSetup] = 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}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh permissions when user logs in (no automatic refresh)
|
||||
useEffect(() => {
|
||||
if (token && user) {
|
||||
// Only refresh permissions once when user logs in
|
||||
refreshPermissions()
|
||||
}
|
||||
}, [token, user])
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchPermissions = async (authToken) => {
|
||||
try {
|
||||
setPermissionsLoading(true)
|
||||
const response = await fetch('/api/v1/permissions/user-permissions', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
})
|
||||
const refreshPermissions = useCallback(async () => {
|
||||
if (token) {
|
||||
const updatedPermissions = await fetchPermissions(token);
|
||||
return updatedPermissions;
|
||||
}
|
||||
return null;
|
||||
}, [token, fetchPermissions]);
|
||||
|
||||
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
|
||||
} finally {
|
||||
setPermissionsLoading(false)
|
||||
}
|
||||
}
|
||||
// Initialize auth state from localStorage
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem("token");
|
||||
const storedUser = localStorage.getItem("user");
|
||||
|
||||
const refreshPermissions = async () => {
|
||||
if (token) {
|
||||
const updatedPermissions = await fetchPermissions(token)
|
||||
return updatedPermissions
|
||||
}
|
||||
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 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) {
|
||||
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' }
|
||||
}
|
||||
}
|
||||
if (response.ok) {
|
||||
// Check if TFA is required
|
||||
if (data.requiresTfa) {
|
||||
return { success: true, requiresTfa: true };
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
// Regular successful login
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
|
||||
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),
|
||||
})
|
||||
// Fetch user permissions after successful login
|
||||
const userPermissions = await fetchPermissions(data.token);
|
||||
if (userPermissions) {
|
||||
setPermissions(userPermissions);
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error || "Login failed" };
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "Network error occurred" };
|
||||
}
|
||||
};
|
||||
|
||||
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 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");
|
||||
}
|
||||
};
|
||||
|
||||
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 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 data = await response.json()
|
||||
const data = await response.json();
|
||||
|
||||
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' }
|
||||
}
|
||||
}
|
||||
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" };
|
||||
}
|
||||
};
|
||||
|
||||
const isAuthenticated = () => {
|
||||
return !!(token && user)
|
||||
}
|
||||
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 isAdmin = () => {
|
||||
return user?.role === 'admin'
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// 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
|
||||
}
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || "Password change failed",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "Network error occurred" };
|
||||
}
|
||||
};
|
||||
|
||||
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')
|
||||
const isAdmin = () => {
|
||||
return user?.role === "admin";
|
||||
};
|
||||
|
||||
// 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'
|
||||
}
|
||||
})
|
||||
// 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;
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setNeedsFirstTimeSetup(!data.hasAdminUsers)
|
||||
} else {
|
||||
// If endpoint doesn't exist or fails, assume setup is needed
|
||||
setNeedsFirstTimeSetup(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking admin users:', error)
|
||||
// If there's an error, assume setup is needed
|
||||
setNeedsFirstTimeSetup(true)
|
||||
} finally {
|
||||
setCheckingSetup(false)
|
||||
}
|
||||
}, [])
|
||||
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");
|
||||
|
||||
// Check for admin users on initial load
|
||||
useEffect(() => {
|
||||
if (!token && !user) {
|
||||
checkAdminUsersExist()
|
||||
} else {
|
||||
setCheckingSetup(false)
|
||||
}
|
||||
}, [token, user, checkAdminUsersExist])
|
||||
// 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",
|
||||
},
|
||||
});
|
||||
|
||||
const setAuthState = (authToken, authUser) => {
|
||||
setToken(authToken)
|
||||
setUser(authUser)
|
||||
localStorage.setItem('token', authToken)
|
||||
localStorage.setItem('user', JSON.stringify(authUser))
|
||||
}
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
permissions,
|
||||
isLoading: isLoading || permissionsLoading || checkingSetup,
|
||||
needsFirstTimeSetup,
|
||||
checkingSetup,
|
||||
login,
|
||||
logout,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
refreshPermissions,
|
||||
setAuthState,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
hasPermission,
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
}
|
||||
// Check for admin users ONLY when in CHECKING_SETUP phase
|
||||
useEffect(() => {
|
||||
if (isAuthPhase.checkingSetup(authPhase)) {
|
||||
checkAdminUsersExist();
|
||||
}
|
||||
}, [authPhase, checkAdminUsersExist]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,58 +1,65 @@
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { versionAPI, settingsAPI } from '../utils/api'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { isAuthReady } from "../constants/authPhases";
|
||||
import { settingsAPI } from "../utils/api";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
const UpdateNotificationContext = createContext()
|
||||
const UpdateNotificationContext = createContext();
|
||||
|
||||
export const useUpdateNotification = () => {
|
||||
const context = useContext(UpdateNotificationContext)
|
||||
if (!context) {
|
||||
throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
const context = useContext(UpdateNotificationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useUpdateNotification must be used within an UpdateNotificationProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const UpdateNotificationProvider = ({ children }) => {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const { user, token } = useAuth()
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { authPhase, isAuthenticated } = useAuth();
|
||||
|
||||
// Ensure settings are loaded
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => settingsAPI.get().then(res => res.data),
|
||||
enabled: !!(user && token),
|
||||
retry: 1
|
||||
})
|
||||
// Ensure settings are loaded - but only after auth is fully ready
|
||||
// This reads cached update info from backend (updated by scheduler)
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||
});
|
||||
|
||||
// Query for update information
|
||||
const { data: updateData, isLoading, error } = useQuery({
|
||||
queryKey: ['updateCheck'],
|
||||
queryFn: () => versionAPI.checkUpdates().then(res => res.data),
|
||||
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
retry: 1,
|
||||
enabled: !!(user && token && settings && !settingsLoading) // Only run when authenticated and settings are loaded
|
||||
})
|
||||
// Read cached update information from settings (no GitHub API calls)
|
||||
// The backend scheduler updates this data periodically
|
||||
const updateAvailable = settings?.is_update_available && !dismissed;
|
||||
const updateInfo = settings
|
||||
? {
|
||||
isUpdateAvailable: settings.is_update_available,
|
||||
latestVersion: settings.latest_version,
|
||||
currentVersion: settings.current_version,
|
||||
last_update_check: settings.last_update_check,
|
||||
}
|
||||
: null;
|
||||
|
||||
const updateAvailable = updateData?.isUpdateAvailable && !dismissed
|
||||
const updateInfo = updateData
|
||||
const isLoading = settingsLoading;
|
||||
const error = null;
|
||||
|
||||
const dismissNotification = () => {
|
||||
setDismissed(true)
|
||||
}
|
||||
const dismissNotification = () => {
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const value = {
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
dismissNotification,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
const value = {
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
dismissNotification,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
|
||||
return (
|
||||
<UpdateNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</UpdateNotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<UpdateNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</UpdateNotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,127 +1,128 @@
|
||||
/** biome-ignore-all lint/suspicious/noUnknownAtRules: Bogus */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: Inter, ui-sans-serif, system-ui;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
}
|
||||
html {
|
||||
font-family: Inter, ui-sans-serif, system-ui;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card hover:shadow-card-hover transition-shadow duration-150;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply badge bg-primary-100 text-primary-800;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
@apply badge bg-secondary-100 text-secondary-800;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply badge bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply badge bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply badge bg-danger-100 text-danger-800;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card hover:shadow-card-hover transition-shadow duration-150;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply badge bg-primary-100 text-primary-800;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
@apply badge bg-secondary-100 text-secondary-800;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply badge bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply badge bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply badge bg-danger-100 text-danger-800;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: #64748b #475569;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
}
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: #64748b #475569;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.jsx";
|
||||
import "./index.css";
|
||||
|
||||
// Create a client for React Query
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,498 +1,530 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Server,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
CheckCircle
|
||||
} from 'lucide-react'
|
||||
import { hostGroupsAPI } from '../utils/api'
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Edit, Plus, Server, Trash2 } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { hostGroupsAPI } from "../utils/api";
|
||||
|
||||
const HostGroups = () => {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [selectedGroup, setSelectedGroup] = useState(null)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [groupToDelete, setGroupToDelete] = useState(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [groupToDelete, setGroupToDelete] = useState(null);
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch host groups
|
||||
const { data: hostGroups, isLoading, error } = useQuery({
|
||||
queryKey: ['hostGroups'],
|
||||
queryFn: () => hostGroupsAPI.list().then(res => res.data),
|
||||
})
|
||||
// Fetch host groups
|
||||
const {
|
||||
data: hostGroups,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["hostGroups"],
|
||||
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Create host group mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => hostGroupsAPI.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowCreateModal(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create host group:', error)
|
||||
}
|
||||
})
|
||||
// Create host group mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => hostGroupsAPI.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowCreateModal(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Update host group mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update host group:', error)
|
||||
}
|
||||
})
|
||||
// Update host group mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete host group mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => hostGroupsAPI.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete host group:', error)
|
||||
}
|
||||
})
|
||||
// Delete host group mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => hostGroupsAPI.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowDeleteModal(false);
|
||||
setGroupToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to delete host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = (data) => {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
const handleCreate = (data) => {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleEdit = (group) => {
|
||||
setSelectedGroup(group)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
const handleEdit = (group) => {
|
||||
setSelectedGroup(group);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleUpdate = (data) => {
|
||||
updateMutation.mutate({ id: selectedGroup.id, data })
|
||||
}
|
||||
const handleUpdate = (data) => {
|
||||
updateMutation.mutate({ id: selectedGroup.id, data });
|
||||
};
|
||||
|
||||
const handleDeleteClick = (group) => {
|
||||
setGroupToDelete(group)
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
const handleDeleteClick = (group) => {
|
||||
setGroupToDelete(group);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
deleteMutation.mutate(groupToDelete.id)
|
||||
}
|
||||
const handleDeleteConfirm = () => {
|
||||
deleteMutation.mutate(groupToDelete.id);
|
||||
};
|
||||
|
||||
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 (error) {
|
||||
return (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load host groups'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || "Failed to load host groups"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
Organize your hosts into logical groups for better management
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
Organize your hosts into logical groups for better management
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Host Groups Grid */}
|
||||
{hostGroups && hostGroups.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{hostGroups.map((group) => (
|
||||
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.description && (
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
|
||||
{group.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(group)}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
|
||||
title="Delete group"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<div className="flex items-center gap-1">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
No host groups yet
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Create your first host group to organize your hosts
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Host Groups Grid */}
|
||||
{hostGroups && hostGroups.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{hostGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.description && (
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
|
||||
{group.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(group)}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
|
||||
title="Delete group"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateHostGroupModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<div className="flex items-center gap-1">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>
|
||||
{group._count.hosts} host
|
||||
{group._count.hosts !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
No host groups yet
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Create your first host group to organize your hosts
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedGroup && (
|
||||
<EditHostGroupModal
|
||||
group={selectedGroup}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateHostGroupModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && groupToDelete && (
|
||||
<DeleteHostGroupModal
|
||||
group={groupToDelete}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedGroup && (
|
||||
<EditHostGroupModal
|
||||
group={selectedGroup}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && groupToDelete && (
|
||||
<DeleteHostGroupModal
|
||||
group={groupToDelete}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setGroupToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Host Group Modal
|
||||
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6'
|
||||
})
|
||||
const nameId = useId();
|
||||
const descriptionId = useId();
|
||||
const colorId = useId();
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
color: "#3B82F6",
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Create Host Group
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Create Host Group
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={nameId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||
>
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id={nameId}
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={descriptionId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id={descriptionId}
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={colorId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
id={colorId}
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={isLoading}>
|
||||
{isLoading ? "Creating..." : "Create Group"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Edit Host Group Modal
|
||||
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
color: group.color || '#3B82F6'
|
||||
})
|
||||
const editNameId = useId();
|
||||
const editDescriptionId = useId();
|
||||
const editColorId = useId();
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
description: group.description || "",
|
||||
color: group.color || "#3B82F6",
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Edit Host Group
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Edit Host Group
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={editNameId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||
>
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id={editNameId}
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={editDescriptionId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id={editDescriptionId}
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={editColorId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
id={editColorId}
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={isLoading}>
|
||||
{isLoading ? "Updating..." : "Update Group"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Host Group
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-200">
|
||||
Are you sure you want to delete the host group{' '}
|
||||
<span className="font-semibold">"{group.name}"</span>?
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
|
||||
You must move or remove these hosts before deleting the group.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="btn-danger"
|
||||
disabled={isLoading || group._count.hosts > 0}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Group'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Host Group
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
export default HostGroups
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-200">
|
||||
Are you sure you want to delete the host group{" "}
|
||||
<span className="font-semibold">"{group.name}"</span>?
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<strong>Warning:</strong> This group contains{" "}
|
||||
{group._count.hosts} host
|
||||
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
|
||||
these hosts before deleting the group.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="btn-danger"
|
||||
disabled={isLoading || group._count.hosts > 0}
|
||||
>
|
||||
{isLoading ? "Deleting..." : "Delete Group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostGroups;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,450 +1,488 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { authAPI } from '../utils/api'
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Mail,
|
||||
Smartphone,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { authAPI } from "../utils/api";
|
||||
|
||||
const Login = () => {
|
||||
const [isSignupMode, setIsSignupMode] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: ''
|
||||
})
|
||||
const [tfaData, setTfaData] = useState({
|
||||
token: ''
|
||||
})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [requiresTfa, setRequiresTfa] = useState(false)
|
||||
const [tfaUsername, setTfaUsername] = useState('')
|
||||
const [signupEnabled, setSignupEnabled] = useState(false)
|
||||
const usernameId = useId();
|
||||
const firstNameId = useId();
|
||||
const lastNameId = useId();
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const tokenId = useId();
|
||||
const { login, setAuthState } = useAuth();
|
||||
const [isSignupMode, setIsSignupMode] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
});
|
||||
const [tfaData, setTfaData] = useState({
|
||||
token: "",
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [requiresTfa, setRequiresTfa] = useState(false);
|
||||
const [tfaUsername, setTfaUsername] = useState("");
|
||||
const [signupEnabled, setSignupEnabled] = useState(false);
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { login, setAuthState } = useAuth()
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if signup is enabled
|
||||
useEffect(() => {
|
||||
const checkSignupEnabled = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/signup-enabled')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSignupEnabled(data.signupEnabled)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check signup status:', error)
|
||||
// Default to disabled on error for security
|
||||
setSignupEnabled(false)
|
||||
}
|
||||
}
|
||||
checkSignupEnabled()
|
||||
}, [])
|
||||
// Check if signup is enabled
|
||||
useEffect(() => {
|
||||
const checkSignupEnabled = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/signup-enabled");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSignupEnabled(data.signupEnabled);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check signup status:", error);
|
||||
// Default to disabled on error for security
|
||||
setSignupEnabled(false);
|
||||
}
|
||||
};
|
||||
checkSignupEnabled();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await authAPI.login(formData.username, formData.password)
|
||||
try {
|
||||
// Use the AuthContext login function which handles everything
|
||||
const result = await login(formData.username, formData.password);
|
||||
|
||||
if (response.data.requiresTfa) {
|
||||
setRequiresTfa(true)
|
||||
setTfaUsername(formData.username)
|
||||
setError('')
|
||||
} else {
|
||||
// Regular login successful
|
||||
const result = await login(formData.username, formData.password)
|
||||
if (result.success) {
|
||||
navigate('/')
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Login failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
if (result.requiresTfa) {
|
||||
setRequiresTfa(true);
|
||||
setTfaUsername(formData.username);
|
||||
setError("");
|
||||
} else if (result.success) {
|
||||
navigate("/");
|
||||
} else {
|
||||
setError(result.error || "Login failed");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || "Login failed");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
const handleSignupSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await authAPI.signup(formData.username, formData.email, formData.password, formData.firstName, formData.lastName)
|
||||
if (response.data && response.data.token) {
|
||||
// Update AuthContext state and localStorage
|
||||
setAuthState(response.data.token, response.data.user)
|
||||
try {
|
||||
const response = await authAPI.signup(
|
||||
formData.username,
|
||||
formData.email,
|
||||
formData.password,
|
||||
formData.firstName,
|
||||
formData.lastName,
|
||||
);
|
||||
if (response.data?.token) {
|
||||
// Update AuthContext state and localStorage
|
||||
setAuthState(response.data.token, response.data.user);
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/')
|
||||
} else {
|
||||
setError('Signup failed - invalid response')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Signup error:', err)
|
||||
const errorMessage = err.response?.data?.error ||
|
||||
(err.response?.data?.errors && err.response.data.errors.length > 0
|
||||
? err.response.data.errors.map(e => e.msg).join(', ')
|
||||
: err.message || 'Signup failed')
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
// Redirect to dashboard
|
||||
navigate("/");
|
||||
} else {
|
||||
setError("Signup failed - invalid response");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Signup error:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
(err.response?.data?.errors && err.response.data.errors.length > 0
|
||||
? err.response.data.errors.map((e) => e.msg).join(", ")
|
||||
: err.message || "Signup failed");
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTfaSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
const handleTfaSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
|
||||
try {
|
||||
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
|
||||
|
||||
if (response.data && response.data.token) {
|
||||
// Store token and user data
|
||||
localStorage.setItem('token', response.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user))
|
||||
if (response.data?.token) {
|
||||
// Update AuthContext with the new authentication state
|
||||
setAuthState(response.data.token, response.data.user);
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/')
|
||||
} else {
|
||||
setError('TFA verification failed - invalid response')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TFA verification error:', err)
|
||||
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed'
|
||||
setError(errorMessage)
|
||||
// Clear the token input for security
|
||||
setTfaData({ token: '' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
// Redirect to dashboard
|
||||
navigate("/");
|
||||
} else {
|
||||
setError("TFA verification failed - invalid response");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("TFA verification error:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.error || err.message || "TFA verification failed";
|
||||
setError(errorMessage);
|
||||
// Clear the token input for security
|
||||
setTfaData({ token: "" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTfaInputChange = (e) => {
|
||||
setTfaData({
|
||||
...tfaData,
|
||||
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
|
||||
})
|
||||
// Clear error when user starts typing
|
||||
if (error) {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
const handleTfaInputChange = (e) => {
|
||||
setTfaData({
|
||||
...tfaData,
|
||||
[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
|
||||
});
|
||||
// Clear error when user starts typing
|
||||
if (error) {
|
||||
setError("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setRequiresTfa(false)
|
||||
setTfaData({ token: '' })
|
||||
setError('')
|
||||
}
|
||||
const handleBackToLogin = () => {
|
||||
setRequiresTfa(false);
|
||||
setTfaData({ token: "" });
|
||||
setError("");
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
// Only allow signup mode if signup is enabled
|
||||
if (!signupEnabled && !isSignupMode) {
|
||||
return // Don't allow switching to signup if disabled
|
||||
}
|
||||
setIsSignupMode(!isSignupMode)
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: ''
|
||||
})
|
||||
setError('')
|
||||
}
|
||||
const toggleMode = () => {
|
||||
// Only allow signup mode if signup is enabled
|
||||
if (!signupEnabled && !isSignupMode) {
|
||||
return; // Don't allow switching to signup if disabled
|
||||
}
|
||||
setIsSignupMode(!isSignupMode);
|
||||
setFormData({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
});
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
|
||||
<Lock size={24} color="#2563eb" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
|
||||
{isSignupMode ? 'Create PatchMon Account' : 'Sign in to PatchMon'}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-secondary-600">
|
||||
Monitor and manage your Linux package updates
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
|
||||
<Lock size={24} color="#2563eb" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
|
||||
{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-secondary-600">
|
||||
Monitor and manage your Linux package updates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!requiresTfa ? (
|
||||
<form className="mt-8 space-y-6" onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
|
||||
{isSignupMode ? 'Username' : 'Username or Email'}
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder={isSignupMode ? "Enter your username" : "Enter your username or email"}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<User
|
||||
size={20}
|
||||
color="#64748b"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!requiresTfa ? (
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={usernameId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
{isSignupMode ? "Username" : "Username or Email"}
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={usernameId}
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder={
|
||||
isSignupMode
|
||||
? "Enter your username"
|
||||
: "Enter your username or email"
|
||||
}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<User size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSignupMode && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700">
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your first name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-secondary-700">
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Mail
|
||||
size={20}
|
||||
color="#64748b"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isSignupMode && (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id={firstNameId}
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your first name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={lastNameId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id={lastNameId}
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={emailId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={emailId}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Mail size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary-700">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Lock
|
||||
size={20}
|
||||
color="#64748b"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={20} color="#64748b" strokeWidth={2} />
|
||||
) : (
|
||||
<Eye size={20} color="#64748b" strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={passwordId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={passwordId}
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Lock size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={20} color="#64748b" strokeWidth={2} />
|
||||
) : (
|
||||
<Eye size={20} color="#64748b" strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{isSignupMode ? 'Creating account...' : 'Signing in...'}
|
||||
</div>
|
||||
) : (
|
||||
isSignupMode ? 'Create Account' : 'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{isSignupMode ? "Creating account..." : "Signing in..."}
|
||||
</div>
|
||||
) : isSignupMode ? (
|
||||
"Create Account"
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{signupEnabled && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
|
||||
>
|
||||
{isSignupMode ? 'Sign in' : 'Sign up'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||
<Smartphone size={24} color="#2563eb" strokeWidth={2} />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-secondary-900">
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-secondary-600">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
{signupEnabled && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
{isSignupMode
|
||||
? "Already have an account?"
|
||||
: "Don't have an account?"}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
|
||||
>
|
||||
{isSignupMode ? "Sign in" : "Sign up"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||
<Smartphone size={24} color="#2563eb" strokeWidth={2} />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-secondary-900">
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-secondary-600">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-secondary-700">
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="token"
|
||||
name="token"
|
||||
type="text"
|
||||
required
|
||||
value={tfaData.token}
|
||||
onChange={handleTfaInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={tokenId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id={tokenId}
|
||||
name="token"
|
||||
type="text"
|
||||
required
|
||||
value={tfaData.token}
|
||||
onChange={handleTfaInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || tfaData.token.length !== 6}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Verifying...
|
||||
</div>
|
||||
) : (
|
||||
'Verify Code'
|
||||
)}
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || tfaData.token.length !== 6}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Verifying...
|
||||
</div>
|
||||
) : (
|
||||
"Verify Code"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
|
||||
>
|
||||
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
|
||||
>
|
||||
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
Don't have access to your authenticator? Use a backup code.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
Don't have access to your authenticator? Use a backup code.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login
|
||||
export default Login;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,479 @@
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Package } from 'lucide-react'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
ChartColumnBig,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { formatRelativeTime, packagesAPI } from "../utils/api";
|
||||
|
||||
const PackageDetail = () => {
|
||||
const { packageId } = useParams()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<div className="card p-8 text-center">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 mb-2">Package Details</h3>
|
||||
<p className="text-secondary-600">
|
||||
Detailed view for package: {packageId}
|
||||
</p>
|
||||
<p className="text-secondary-600 mt-2">
|
||||
This page will show package information, affected hosts, version distribution, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { packageId } = useParams();
|
||||
const decodedPackageId = decodeURIComponent(packageId || "");
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
export default PackageDetail
|
||||
// Fetch package details
|
||||
const {
|
||||
data: packageData,
|
||||
isLoading: isLoadingPackage,
|
||||
error: packageError,
|
||||
refetch: refetchPackage,
|
||||
} = useQuery({
|
||||
queryKey: ["package", decodedPackageId],
|
||||
queryFn: () =>
|
||||
packagesAPI.getById(decodedPackageId).then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!decodedPackageId,
|
||||
});
|
||||
|
||||
// Fetch hosts that have this package
|
||||
const {
|
||||
data: hostsData,
|
||||
isLoading: isLoadingHosts,
|
||||
error: hostsError,
|
||||
refetch: refetchHosts,
|
||||
} = useQuery({
|
||||
queryKey: ["package-hosts", decodedPackageId, searchTerm],
|
||||
queryFn: () =>
|
||||
packagesAPI
|
||||
.getHosts(decodedPackageId, { search: searchTerm, limit: 1000 })
|
||||
.then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!decodedPackageId,
|
||||
});
|
||||
|
||||
const hosts = hostsData?.hosts || [];
|
||||
|
||||
// Filter and paginate hosts
|
||||
const filteredAndPaginatedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = hosts.filter(
|
||||
(host) =>
|
||||
host.friendlyName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return filtered.slice(startIndex, endIndex);
|
||||
}, [hosts, searchTerm, currentPage, pageSize]);
|
||||
|
||||
const totalPages = Math.ceil(
|
||||
(searchTerm
|
||||
? hosts.filter(
|
||||
(host) =>
|
||||
host.friendlyName
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
).length
|
||||
: hosts.length) / pageSize,
|
||||
);
|
||||
|
||||
const handleHostClick = (hostId) => {
|
||||
navigate(`/hosts/${hostId}`);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchPackage();
|
||||
refetchHosts();
|
||||
};
|
||||
|
||||
if (isLoadingPackage) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (packageError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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 package
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{packageError.message || "Failed to load package details"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchPackage()}
|
||||
className="mt-2 btn-danger text-xs"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!packageData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
Package not found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pkg = packageData;
|
||||
const stats = packageData.stats || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/packages")}
|
||||
className="flex items-center gap-2 text-secondary-600 hover:text-secondary-900 dark:text-secondary-400 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Packages
|
||||
</button>
|
||||
<ChevronRight className="h-4 w-4 text-secondary-400" />
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
{pkg.name}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoadingPackage || isLoadingHosts}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${
|
||||
isLoadingPackage || isLoadingHosts ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Package Overview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Package Info */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<Package className="h-8 w-8 text-primary-600 flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
|
||||
{pkg.name}
|
||||
</h2>
|
||||
{pkg.description && (
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
{pkg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{pkg.category && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Category: {pkg.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pkg.latest_version && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Latest: {pkg.latest_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pkg.updated_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Updated: {formatRelativeTime(pkg.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mb-4">
|
||||
{stats.updatesNeeded > 0 ? (
|
||||
stats.securityUpdates > 0 ? (
|
||||
<span className="badge-danger flex items-center gap-1 w-fit">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning w-fit">Update Available</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success w-fit">Up to Date</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ChartColumnBig className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="font-medium text-secondary-900 dark:text-white">
|
||||
Installation Stats
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Total Installations
|
||||
</span>
|
||||
<span className="font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.totalInstalls || 0}
|
||||
</span>
|
||||
</div>
|
||||
{stats.updatesNeeded > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Hosts Needing Updates
|
||||
</span>
|
||||
<span className="font-semibold text-warning-600">
|
||||
{stats.updatesNeeded}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.securityUpdates > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Security Updates
|
||||
</span>
|
||||
<span className="font-semibold text-danger-600">
|
||||
{stats.securityUpdates}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Up to Date
|
||||
</span>
|
||||
<span className="font-semibold text-success-600">
|
||||
{(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosts List */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Installed On Hosts ({hosts.length})
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search hosts..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{isLoadingHosts ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : hostsError ? (
|
||||
<div className="p-6">
|
||||
<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 hosts
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{hostsError.message || "Failed to load hosts"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredAndPaginatedHosts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
{searchTerm
|
||||
? "No hosts match your search"
|
||||
: "No hosts have this package installed"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
Host
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Current Version
|
||||
</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">
|
||||
Last Updated
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndPaginatedHosts.map((host) => (
|
||||
<tr
|
||||
key={host.hostId}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
|
||||
onClick={() => handleHostClick(host.hostId)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{host.friendlyName || host.hostname}
|
||||
</div>
|
||||
{host.friendlyName && host.hostname && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{host.hostname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{host.currentVersion || "Unknown"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{host.needsUpdate ? (
|
||||
host.isSecurityUpdate ? (
|
||||
<span className="badge-danger flex items-center gap-1 w-fit">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning w-fit">
|
||||
Update Available
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success w-fit">
|
||||
Up to Date
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{host.lastUpdate
|
||||
? formatRelativeTime(host.lastUpdate)
|
||||
: "Never"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Rows per page:
|
||||
</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackageDetail;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,394 +1,497 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Shield,
|
||||
Settings,
|
||||
Users,
|
||||
Server,
|
||||
Package,
|
||||
BarChart3,
|
||||
Download,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
X,
|
||||
AlertTriangle,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { permissionsAPI } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Download,
|
||||
Edit,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
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 Permissions = () => {
|
||||
const [editingRole, setEditingRole] = useState(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { refreshPermissions } = useAuth()
|
||||
const [editingRole, setEditingRole] = useState(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { refreshPermissions } = useAuth();
|
||||
|
||||
// Fetch all role permissions
|
||||
const { data: roles, isLoading, error } = useQuery({
|
||||
queryKey: ['rolePermissions'],
|
||||
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
|
||||
})
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
// 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'])
|
||||
}
|
||||
})
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (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>
|
||||
)
|
||||
}
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="flex justify-end items-center">
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => refreshPermissions()}
|
||||
className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh Permissions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-end items-center">
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refreshPermissions()}
|
||||
className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh Permissions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roles List */}
|
||||
<div className="space-y-4">
|
||||
{roles && Array.isArray(roles) && roles.map((role) => (
|
||||
<RolePermissionsCard
|
||||
key={role.id}
|
||||
role={role}
|
||||
isEditing={editingRole === role.role}
|
||||
onEdit={() => setEditingRole(role.role)}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
onSave={handleSavePermissions}
|
||||
onDelete={handleDeleteRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Roles List */}
|
||||
<div className="space-y-4">
|
||||
{roles &&
|
||||
Array.isArray(roles) &&
|
||||
roles.map((role) => (
|
||||
<RolePermissionsCard
|
||||
key={role.id}
|
||||
role={role}
|
||||
isEditing={editingRole === role.role}
|
||||
onEdit={() => setEditingRole(role.role)}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
onSave={handleSavePermissions}
|
||||
onDelete={handleDeleteRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Role Modal */}
|
||||
<AddRoleModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries(['rolePermissions'])
|
||||
setShowAddModal(false)
|
||||
}}
|
||||
/>
|
||||
</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)
|
||||
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])
|
||||
// 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 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 handlePermissionChange = (key, value) => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(role.role, permissions)
|
||||
}
|
||||
const handleSave = () => {
|
||||
onSave(role.role, permissions);
|
||||
};
|
||||
|
||||
const isBuiltInRole = role.role === 'admin' || role.role === 'user'
|
||||
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
|
||||
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
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
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>
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
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 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>
|
||||
)
|
||||
}
|
||||
<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 [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 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('')
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null
|
||||
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 className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Role Name
|
||||
</label>
|
||||
<input
|
||||
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>
|
||||
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>
|
||||
|
||||
<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
|
||||
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 className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
|
||||
{permission.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{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="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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
{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>
|
||||
)}
|
||||
|
||||
export default Permissions
|
||||
<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 Permissions;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
67
frontend/src/pages/settings/AlertChannels.jsx
Normal file
67
frontend/src/pages/settings/AlertChannels.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Bell } from "lucide-react";
|
||||
|
||||
const AlertChannels = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Alert Channels
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Configure how PatchMon sends notifications and alerts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coming Soon Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Alert Channels Coming Soon
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
We're working on adding support for multiple alert channels
|
||||
including email, Slack, Discord, and webhooks.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
|
||||
In Development
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for future channels */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Alert Channels
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
No alert channels configured yet
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-8 text-center">
|
||||
<Bell className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
|
||||
No channels
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Get started by adding your first alert channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertChannels;
|
||||
753
frontend/src/pages/settings/Integrations.jsx
Normal file
753
frontend/src/pages/settings/Integrations.jsx
Normal file
@@ -0,0 +1,753 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Plus,
|
||||
Server,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import SettingsLayout from "../../components/SettingsLayout";
|
||||
import api from "../../utils/api";
|
||||
|
||||
const Integrations = () => {
|
||||
const [activeTab, setActiveTab] = useState("proxmox");
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [host_groups, setHostGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [show_create_modal, setShowCreateModal] = useState(false);
|
||||
const [new_token, setNewToken] = useState(null);
|
||||
const [show_secret, setShowSecret] = useState(false);
|
||||
const [server_url, setServerUrl] = useState("");
|
||||
const [force_proxmox_install, setForceProxmoxInstall] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [form_data, setFormData] = useState({
|
||||
token_name: "",
|
||||
max_hosts_per_day: 100,
|
||||
default_host_group_id: "",
|
||||
allowed_ip_ranges: "",
|
||||
expires_at: "",
|
||||
});
|
||||
|
||||
const [copy_success, setCopySuccess] = useState({});
|
||||
|
||||
// Helper function to build Proxmox enrollment URL with optional force flag
|
||||
const getProxmoxUrl = () => {
|
||||
const baseUrl = `${server_url}/api/v1/auto-enrollment/proxmox-lxc?token_key=${new_token.token_key}&token_secret=${new_token.token_secret}`;
|
||||
return force_proxmox_install ? `${baseUrl}&force=true` : baseUrl;
|
||||
};
|
||||
|
||||
const handleTabChange = (tabName) => {
|
||||
setActiveTab(tabName);
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount
|
||||
useEffect(() => {
|
||||
load_tokens();
|
||||
load_host_groups();
|
||||
load_server_url();
|
||||
}, []);
|
||||
|
||||
const load_tokens = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get("/auto-enrollment/tokens");
|
||||
setTokens(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tokens:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const load_host_groups = async () => {
|
||||
try {
|
||||
const response = await api.get("/host-groups");
|
||||
setHostGroups(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load host groups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const load_server_url = async () => {
|
||||
try {
|
||||
const response = await api.get("/settings");
|
||||
setServerUrl(response.data.server_url || window.location.origin);
|
||||
} catch (error) {
|
||||
console.error("Failed to load server URL:", error);
|
||||
setServerUrl(window.location.origin);
|
||||
}
|
||||
};
|
||||
|
||||
const create_token = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const data = {
|
||||
token_name: form_data.token_name,
|
||||
max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10),
|
||||
allowed_ip_ranges: form_data.allowed_ip_ranges
|
||||
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
|
||||
: [],
|
||||
metadata: {
|
||||
integration_type: "proxmox-lxc",
|
||||
},
|
||||
};
|
||||
|
||||
// Only add optional fields if they have values
|
||||
if (form_data.default_host_group_id) {
|
||||
data.default_host_group_id = form_data.default_host_group_id;
|
||||
}
|
||||
if (form_data.expires_at) {
|
||||
data.expires_at = form_data.expires_at;
|
||||
}
|
||||
|
||||
const response = await api.post("/auto-enrollment/tokens", data);
|
||||
setNewToken(response.data.token);
|
||||
setShowCreateModal(false);
|
||||
load_tokens();
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
token_name: "",
|
||||
max_hosts_per_day: 100,
|
||||
default_host_group_id: "",
|
||||
allowed_ip_ranges: "",
|
||||
expires_at: "",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create token:", error);
|
||||
const error_message = error.response?.data?.errors
|
||||
? error.response.data.errors.map((e) => e.msg).join(", ")
|
||||
: error.response?.data?.error || "Failed to create token";
|
||||
alert(error_message);
|
||||
}
|
||||
};
|
||||
|
||||
const delete_token = async (id, name) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete the token "${name}"? This action cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/auto-enrollment/tokens/${id}`);
|
||||
load_tokens();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete token:", error);
|
||||
alert(error.response?.data?.error || "Failed to delete token");
|
||||
}
|
||||
};
|
||||
|
||||
const toggle_token_active = async (id, current_status) => {
|
||||
try {
|
||||
await api.patch(`/auto-enrollment/tokens/${id}`, {
|
||||
is_active: !current_status,
|
||||
});
|
||||
load_tokens();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle token:", error);
|
||||
alert(error.response?.data?.error || "Failed to toggle token");
|
||||
}
|
||||
};
|
||||
|
||||
const copy_to_clipboard = (text, key) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopySuccess({ ...copy_success, [key]: true });
|
||||
setTimeout(() => {
|
||||
setCopySuccess({ ...copy_success, [key]: false });
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const format_date = (date_string) => {
|
||||
if (!date_string) return "Never";
|
||||
return new Date(date_string).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Integrations
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Manage auto-enrollment tokens for Proxmox and other integrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg overflow-hidden">
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600 flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("proxmox")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === "proxmox"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
}`}
|
||||
>
|
||||
Proxmox LXC
|
||||
</button>
|
||||
{/* Future tabs can be added here */}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{/* Proxmox Tab */}
|
||||
{activeTab === "proxmox" && (
|
||||
<div className="space-y-6">
|
||||
{/* Header with New Token Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||
<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Proxmox LXC Auto-Enrollment
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Automatically discover and enroll LXC containers from
|
||||
Proxmox hosts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Token List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-secondary-600 dark:text-secondary-400">
|
||||
<p>No auto-enrollment tokens created yet.</p>
|
||||
<p className="text-sm mt-2">
|
||||
Create a token to enable automatic host enrollment from
|
||||
Proxmox.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-medium text-secondary-900 dark:text-white">
|
||||
{token.token_name}
|
||||
</h4>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Proxmox LXC
|
||||
</span>
|
||||
{token.is_active ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded">
|
||||
{token.token_key}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
token.token_key,
|
||||
`key-${token.id}`,
|
||||
)
|
||||
}
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
||||
>
|
||||
{copy_success[`key-${token.id}`] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Usage: {token.hosts_created_today}/
|
||||
{token.max_hosts_per_day} hosts today
|
||||
</p>
|
||||
{token.host_groups && (
|
||||
<p>
|
||||
Default Group:{" "}
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${token.host_groups.color}20`,
|
||||
color: token.host_groups.color,
|
||||
}}
|
||||
>
|
||||
{token.host_groups.name}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{token.allowed_ip_ranges?.length > 0 && (
|
||||
<p>
|
||||
Allowed IPs:{" "}
|
||||
{token.allowed_ip_ranges.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p>Created: {format_date(token.created_at)}</p>
|
||||
{token.last_used_at && (
|
||||
<p>
|
||||
Last Used: {format_date(token.last_used_at)}
|
||||
</p>
|
||||
)}
|
||||
{token.expires_at && (
|
||||
<p>
|
||||
Expires: {format_date(token.expires_at)}
|
||||
{new Date(token.expires_at) < new Date() && (
|
||||
<span className="ml-2 text-red-600 dark:text-red-400">
|
||||
(Expired)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggle_token_active(token.id, token.is_active)
|
||||
}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
token.is_active
|
||||
? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300"
|
||||
: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{token.is_active ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
delete_token(token.id, token.token_name)
|
||||
}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-3">
|
||||
How to Use Auto-Enrollment
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300">
|
||||
<li>
|
||||
Create a new auto-enrollment token using the button above
|
||||
</li>
|
||||
<li>
|
||||
Copy the one-line installation command shown in the
|
||||
success dialog
|
||||
</li>
|
||||
<li>SSH into your Proxmox host as root</li>
|
||||
<li>
|
||||
Paste and run the command - it will automatically discover
|
||||
and enroll all running LXC containers
|
||||
</li>
|
||||
<li>View enrolled containers in the Hosts page</li>
|
||||
</ol>
|
||||
<div className="mt-4 p-3 bg-primary-100 dark:bg-primary-900/40 rounded border border-primary-200 dark:border-primary-700">
|
||||
<p className="text-xs text-primary-800 dark:text-primary-300">
|
||||
<strong>💡 Tip:</strong> You can run the same command
|
||||
multiple times safely - already enrolled containers will
|
||||
be automatically skipped.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Token Modal */}
|
||||
{show_create_modal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
Create Auto-Enrollment Token
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={create_token} className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Token Name *
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form_data.token_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...form_data, token_name: e.target.value })
|
||||
}
|
||||
placeholder="e.g., Proxmox Production"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Max Hosts Per Day
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={form_data.max_hosts_per_day}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...form_data,
|
||||
max_hosts_per_day: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md 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">
|
||||
Maximum number of hosts that can be enrolled per day using
|
||||
this token
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Default Host Group (Optional)
|
||||
</span>
|
||||
<select
|
||||
value={form_data.default_host_group_id}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...form_data,
|
||||
default_host_group_id: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="">No default group</option>
|
||||
{host_groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Auto-enrolled hosts will be assigned to this group
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Allowed IP Addresses (Optional)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form_data.allowed_ip_ranges}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...form_data,
|
||||
allowed_ip_ranges: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., 192.168.1.100, 10.0.0.50"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md 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">
|
||||
Comma-separated list of IP addresses allowed to use this
|
||||
token
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Expiration Date (Optional)
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form_data.expires_at}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...form_data, expires_at: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md"
|
||||
>
|
||||
Create Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Token Display Modal */}
|
||||
{new_token && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
Token Created Successfully
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Save these credentials now - the secret will not be shown
|
||||
again!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Important:</strong> Store the token secret securely.
|
||||
You will not be able to view it again after closing this
|
||||
dialog.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Token Name
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={new_token.token_name}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Token Key
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={new_token.token_key}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(new_token.token_key, "new-key")
|
||||
}
|
||||
className="btn-primary flex items-center gap-1 px-3 py-2"
|
||||
>
|
||||
{copy_success["new-key"] ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Token Secret
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type={show_secret ? "text" : "password"}
|
||||
value={new_token.token_secret}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(!show_secret)}
|
||||
className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400 dark:hover:text-secondary-200"
|
||||
>
|
||||
{show_secret ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(new_token.token_secret, "new-secret")
|
||||
}
|
||||
className="btn-primary flex items-center gap-1 px-3 py-2"
|
||||
>
|
||||
{copy_success["new-secret"] ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
One-Line Installation Command
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Run this command on your Proxmox host to download and
|
||||
execute the enrollment script:
|
||||
</p>
|
||||
|
||||
{/* Force Install Toggle */}
|
||||
<div className="mb-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={force_proxmox_install}
|
||||
onChange={(e) =>
|
||||
setForceProxmoxInstall(e.target.checked)
|
||||
}
|
||||
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400 dark:bg-secondary-700"
|
||||
/>
|
||||
<span className="text-secondary-800 dark:text-secondary-200">
|
||||
Force install (bypass broken packages in containers)
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Enable this if your LXC containers have broken packages
|
||||
(CloudPanel, WHM, etc.) that block apt-get operations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl -s "${getProxmoxUrl()}" | bash`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
`curl -s "${getProxmoxUrl()}" | bash`,
|
||||
"curl-command",
|
||||
)
|
||||
}
|
||||
className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
|
||||
>
|
||||
{copy_success["curl-command"] ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
|
||||
💡 This command will automatically discover and enroll all
|
||||
running LXC containers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewToken(null);
|
||||
setShowSecret(false);
|
||||
}}
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md"
|
||||
>
|
||||
I've Saved the Credentials
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Integrations;
|
||||
112
frontend/src/pages/settings/Notifications.jsx
Normal file
112
frontend/src/pages/settings/Notifications.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Bell, Settings } from "lucide-react";
|
||||
|
||||
const Notifications = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Notifications
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Configure notification preferences and alert rules
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coming Soon Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||
<Bell className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Notification Rules Coming Soon
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
We're working on adding comprehensive notification rules including
|
||||
package updates, security alerts, system events, and custom
|
||||
triggers.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
|
||||
In Development
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Settings Preview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
||||
{/* Package Updates */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||
<Settings className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Package Updates
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Get notified when packages are updated on your hosts
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Critical Updates
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Security Patches
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Major Updates
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Notification Rules
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
No notification rules configured yet
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-8 text-center">
|
||||
<Bell className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
|
||||
No rules
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Get started by creating your first notification rule.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user