mirror of
https://github.com/Ravinou/borgwarehouse
synced 2026-03-14 22:35:46 +01:00
Compare commits
334 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a10003b43 | ||
|
|
3316be7b3c | ||
|
|
3772949fdb | ||
|
|
80b440e2b9 | ||
|
|
d7487fbc4a | ||
|
|
4b76223097 | ||
|
|
735a043858 | ||
|
|
0f2a4a716f | ||
|
|
29ca7dfeec | ||
|
|
1ddaa0f648 | ||
|
|
577fdbcd68 | ||
|
|
836450e8db | ||
|
|
efd7083463 | ||
|
|
187f9839f3 | ||
|
|
afa5c9bb1f | ||
|
|
a1bd3deb1d | ||
|
|
8f7be1d14e | ||
|
|
42cca2763f | ||
|
|
916b562f73 | ||
|
|
e34037ebce | ||
|
|
c819521b05 | ||
|
|
c5e99cabe5 | ||
|
|
079bf35d87 | ||
|
|
14dc526177 | ||
|
|
9d6211b0aa | ||
|
|
3fcd39cf40 |
||
|
|
85b9ccaf04 |
||
|
|
d944a70497 |
||
|
|
961d30354d |
||
|
|
ccfb21a790 |
||
|
|
3925c8ab8c |
||
|
|
1faa710105 |
||
|
|
2db5e65f9a |
||
|
|
5bb148459c |
||
|
|
9981b81cf1 |
||
|
|
a240f66e90 |
||
|
|
12be382b75 |
||
|
|
311fb04c34 |
||
|
|
abe1fd74e0 |
||
|
|
4d3336ff97 |
||
|
|
9e6df1e5b3 |
||
|
|
897152d7fc |
||
|
|
3ccf5abc3d |
||
|
|
02cd028c40 |
||
|
|
5636142104 |
||
|
|
7c1790eb8c |
||
|
|
904fa5928b |
||
|
|
446e75d7aa |
||
|
|
cf7b43625c |
||
|
|
d201b290e9 |
||
|
|
7b8c4d6017 |
||
|
|
13872ed29e |
||
|
|
90506c0827 |
||
|
|
9c267e75ec |
||
|
|
d9a8ecf70b |
||
|
|
2ce7232849 |
||
|
|
3ff2ace3bb |
||
|
|
d5e8064348 |
||
|
|
dbe7b4081e |
||
|
|
6a3625ef98 |
||
|
|
f1731d769d |
||
|
|
63216622a6 |
||
|
|
f5af821d47 |
||
|
|
340f186a37 |
||
|
|
53b29ea6f9 |
||
|
|
665974a15a |
||
|
|
ac3c4c7b1d |
||
|
|
2d094cfed9 |
||
|
|
aeb299ab3b |
||
|
|
53079bebce |
||
|
|
5a96fb88b6 |
||
|
|
b988b81089 |
||
|
|
b3d517d791 |
||
|
|
5ee55d18df |
||
|
|
eed58c129e |
||
|
|
d5a440448e |
||
|
|
b6cc2c351e |
||
|
|
b38ffd7be7 |
||
|
|
4009b0cf8b |
||
|
|
599fe35ddc |
||
|
|
97b31e6bae |
||
|
|
0c6e24f599 |
||
|
|
980399d238 |
||
|
|
4189cc34ee |
||
|
|
6565442042 |
||
|
|
4e7b880624 |
||
|
|
c2912253df |
||
|
|
4f011f6c48 |
||
|
|
8202bcd2ad |
||
|
|
1ea2174f9f |
||
|
|
e3a973c09c |
||
|
|
57e4550e42 |
||
|
|
ceeb7a3e6a |
||
|
|
3df990f217 |
||
|
|
ca9097a2aa |
||
|
|
aa8e493e37 |
||
|
|
b6652a80f5 |
||
|
|
88c8f920d9 |
||
|
|
3c1ff79add |
||
|
|
6acfdbbfa1 |
||
|
|
0263edd44f |
||
|
|
05ba852371 |
||
|
|
f15fbbcb6d |
||
|
|
3f47efc572 |
||
|
|
1feb666c4c |
||
|
|
9921d9d40d |
||
|
|
64fcafdfa0 |
||
|
|
5e420d04ca |
||
|
|
220f88bd6d |
||
|
|
cb64164b01 |
||
|
|
6680f48253 |
||
|
|
d808f464ad |
||
|
|
3337e9b97a |
||
|
|
7edfc75379 |
||
|
|
e2c74d067b |
||
|
|
250bf4ef0c |
||
|
|
26c784900a |
||
|
|
092a1d8d3d |
||
|
|
4514d6b7f2 |
||
|
|
754eef3b41 |
||
|
|
db4749479f |
||
|
|
515535a5b3 |
||
|
|
aa85cee260 |
||
|
|
62aedfa1e1 |
||
|
|
0f03b26a63 |
||
|
|
b4c62818e0 |
||
|
|
964f011a8a |
||
|
|
bcd7d25cd2 |
||
|
|
f792e65b88 |
||
|
|
a01980a323 |
||
|
|
4b1c8c5930 |
||
|
|
29e748e0d6 |
||
|
|
680e826a4f |
||
|
|
fd0ba8baaa |
||
|
|
c65b8a9e5e |
||
|
|
73ecb4bf72 |
||
|
|
720328db0c |
||
|
|
cb6e1413d7 |
||
|
|
10e13763fa |
||
|
|
49e1f8c2a2 |
||
|
|
d2acc5bba0 |
||
|
|
05f532c10d |
||
|
|
8be42035e7 |
||
|
|
33a0de52e3 |
||
|
|
18925266f8 |
||
|
|
933f5931e2 |
||
|
|
d50750033e |
||
|
|
f0783a3027 |
||
|
|
939f56137b |
||
|
|
05a76a5f2a |
||
|
|
e4f694d383 |
||
|
|
25022778b9 |
||
|
|
b1cee4486b |
||
|
|
e6310a5412 |
||
|
|
a0924a8ba9 |
||
|
|
daa199dfb0 |
||
|
|
799bf03cd5 |
||
|
|
d601c6dadf |
||
|
|
d6245d65c4 |
||
|
|
56ff17853a |
||
|
|
1e4a34edce |
||
|
|
afe828fc1a |
||
|
|
5567cddfdb |
||
|
|
766a63d524 |
||
|
|
a62e55b42a |
||
|
|
a3d156bdbf |
||
|
|
144bea3947 |
||
|
|
6f24a63077 |
||
|
|
58f55fa9fc |
||
|
|
fb61846bbb |
||
|
|
4de1884de8 |
||
|
|
c5e206a818 |
||
|
|
f7faada494 |
||
|
|
3d66ff18e6 |
||
|
|
03e4b175df |
||
|
|
0ee771f64a |
||
|
|
533bfce0d0 |
||
|
|
fec9ba21ad |
||
|
|
a9dadb9a53 |
||
|
|
ae27636dac |
||
|
|
86133a64b0 |
||
|
|
db36c806b6 |
||
|
|
ff25907bb3 |
||
|
|
e939b704ef |
||
|
|
90816bd705 |
||
|
|
c6911e77d2 |
||
|
|
26f8864ebf |
||
|
|
8237b428bc |
||
|
|
73842a8d62 |
||
|
|
7266ea464e |
||
|
|
837b5f01f9 |
||
|
|
8b16a713a5 |
||
|
|
3815109958 |
||
|
|
785413eec7 |
||
|
|
e1f234d54b |
||
|
|
52d8bca2ad |
||
|
|
9e2ae9f0fa |
||
|
|
201f5b41a1 |
||
|
|
2463a61943 |
||
|
|
ca8199ca33 |
||
|
|
7ec99a75c7 |
||
|
|
3105963b11 |
||
|
|
11aa62a548 |
||
|
|
e4dc585fe5 |
||
|
|
49cfbf44e0 |
||
|
|
ddbb629d75 |
||
|
|
fb68c4331b |
||
|
|
8b4ca5d7bc |
||
|
|
2316fb573e |
||
|
|
cb2032f309 |
||
|
|
1ae96c8f9a |
||
|
|
b7d3aec3b1 |
||
|
|
93000d4406 |
||
|
|
149fad13ec |
||
|
|
35ad73fd23 |
||
|
|
dac0c41df4 |
||
|
|
da60d50dcb |
||
|
|
d753df49a0 |
||
|
|
b40c7d7343 |
||
|
|
73c8350442 |
||
|
|
6a661a4f6a |
||
|
|
c6111329de |
||
|
|
8a0a69b7dc |
||
|
|
5ce6e2c19c |
||
|
|
e1cd8e1642 |
||
|
|
f9856e5689 |
||
|
|
70eaa38f1f |
||
|
|
acdaaffc16 |
||
|
|
b266787295 |
||
|
|
7625e5af02 |
||
|
|
73e35295dc |
||
|
|
313a2f30f9 |
||
|
|
21330fa672 |
||
|
|
7aa47195f1 |
||
|
|
086ae6dad3 |
||
|
|
67861260f8 |
||
|
|
d7bd79b5b4 |
||
|
|
0c4d5a898b |
||
|
|
46b923da77 |
||
|
|
80277dbe75 |
||
|
|
448781c3c3 |
||
|
|
c4f59c905b |
||
|
|
8a64fe16da |
||
|
|
940367e6b2 |
||
|
|
233b621bc7 |
||
|
|
0d3377baa6 |
||
|
|
d66e7a2263 |
||
|
|
b32318ccc7 |
||
|
|
349275b908 |
||
|
|
12de337017 |
||
|
|
d245e30af7 |
||
|
|
7b6d1a2785 |
||
|
|
f228117720 |
||
|
|
d9500df622 |
||
|
|
4f175114ff |
||
|
|
e323cdd9b8 |
||
|
|
5e66de2f64 |
||
|
|
e485d6f394 |
||
|
|
cd24d4479f |
||
|
|
d84215df4b |
||
|
|
a5f3530431 |
||
|
|
5da8e61b8f |
||
|
|
82aa9015c8 |
||
|
|
42a6f0f551 |
||
|
|
e984dcf17b |
||
|
|
4e78e65d2d |
||
|
|
e652a95a0b |
||
|
|
ff4a676f32 |
||
|
|
1ba028ad14 |
||
|
|
b501cbe93c |
||
|
|
85eb0891c6 |
||
|
|
b29c6ba7b3 |
||
|
|
3afeeb7134 |
||
|
|
70faeba69a |
||
|
|
2a862e23bd |
||
|
|
7477dcfdbd |
||
|
|
fa3e2067c9 |
||
|
|
2664f2b3d7 |
||
|
|
08bb4ebe70 |
||
|
|
edbaf23f3f |
||
|
|
53d749e529 |
||
|
|
2bab3f1180 |
||
|
|
d003d2173b |
||
|
|
4e0dc479bd |
||
|
|
e1263867f8 |
||
|
|
f7b7f62069 |
||
|
|
31a1ad9769 |
||
|
|
09f2b9b599 |
||
|
|
6eedd52a70 |
||
|
|
b22dcdaa4d |
||
|
|
1b6c0ae4be |
||
|
|
39a8335787 |
||
|
|
28ae1ff42c |
||
|
|
36bc28d85a |
||
|
|
ed247c592f |
||
|
|
5150257441 |
||
|
|
22779c590e |
||
|
|
c3d1469d5a |
||
|
|
8e80bfa49c |
||
|
|
5af9e0e2e3 |
||
|
|
2ac4fa3b2a |
||
|
|
a19bb130f0 |
||
|
|
307b2a5676 |
||
|
|
b8b20ebc7c |
||
|
|
1dca17b50f |
||
|
|
7415039a2b |
||
|
|
333d7fa119 |
||
|
|
7c3632a131 |
||
|
|
4d32f1e002 |
||
|
|
4f9234e325 |
||
|
|
9a33efc121 |
||
|
|
f5bbae9c0d |
||
|
|
2d2446ea51 |
||
|
|
97d909aaed |
||
|
|
0198051eb0 |
||
|
|
28c7e60481 |
||
|
|
cedcdb2418 |
||
|
|
55fd807e8e |
||
|
|
c546bc7bc2 |
||
|
|
c8dbaac92e |
||
|
|
0c407cd732 |
||
|
|
1e08e64ce6 |
||
|
|
8e9fa1eb56 |
||
|
|
66047df78a |
||
|
|
96ae9b8f65 |
||
|
|
88a80f42e8 |
||
|
|
0d645fc461 |
||
|
|
bdc0104c9a |
||
|
|
0935409d08 |
||
|
|
a70e39bd6d |
||
|
|
904eb1db60 |
||
|
|
94ce693c54 |
||
|
|
14ba99028e |
||
|
|
b2c9d6d9ae |
194 changed files with 14130 additions and 11250 deletions
|
|
@ -1,4 +1,4 @@
|
|||
export default {
|
||||
const config = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
|
|
@ -19,8 +19,12 @@ export default {
|
|||
'ui',
|
||||
'wip',
|
||||
'publish',
|
||||
'docker',
|
||||
'WIP',
|
||||
],
|
||||
],
|
||||
},
|
||||
ignores: [(message) => message.includes('WIP'), (message) => message.includes('wip')],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ CONFIG_PATH=./config
|
|||
SSH_PATH=./ssh
|
||||
SSH_HOST=./ssh_host
|
||||
BORG_REPOSITORY_PATH=./repos
|
||||
TMP_PATH=./tmp
|
||||
LOGS_PATH=./logs
|
||||
|
||||
## Optional variables section ##
|
||||
|
||||
|
|
|
|||
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report a bug
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**BorgWarehouse version :**
|
||||
**Installation type :**
|
||||
- [ ] Docker
|
||||
- [ ] Baremetal (Debian/Ubuntu)
|
||||
- [ ] Other environment :
|
||||
|
||||
-------
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Please, [BorgWarehouse's documentation](https://borgwarehouse.com/)
|
||||
is up to date and comprehensive, so take the time to look for answers. You can also look for answers in the project's historical [github issues](https://github.com/Ravinou/borgwarehouse/issues?q=is%3Aissue%20state%3Aclosed). I take time to answer each issue, but it's always less time for BorgWarehouse development. Thanks in advance.**
|
||||
21
.github/ISSUE_TEMPLATE/i-need-help.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/i-need-help.md
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
name: I need help
|
||||
about: You need help about installation, usage, or specific cases.
|
||||
title: ''
|
||||
labels: help wanted
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**BorgWarehouse version :**
|
||||
**Installation type :**
|
||||
- [ ] Docker
|
||||
- [ ] Baremetal (Debian/Ubuntu)
|
||||
- [ ] Other environment :
|
||||
|
||||
-------
|
||||
|
||||
Describe your problem here.
|
||||
|
||||
**Please, [BorgWarehouse's documentation](https://borgwarehouse.com/)
|
||||
is up to date and comprehensive, so take the time to look for answers. You can also look for answers in the project's historical [github issues](https://github.com/Ravinou/borgwarehouse/issues?q=is%3Aissue%20state%3Aclosed). I take time to answer each issue, but it's always less time for BorgWarehouse development. Thanks in advance.**
|
||||
20
.github/dependabot.yml
vendored
20
.github/dependabot.yml
vendored
|
|
@ -1,16 +1,18 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
interval: 'daily'
|
||||
# Note: Dependabot uses "npm" ecosystem but automatically detects pnpm-lock.yaml
|
||||
# Make sure package-lock.json is gitignored to prevent confusion
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: 'daily'
|
||||
# Maintain dependencies for GitHub Actions
|
||||
# src: https://github.com/marketplace/actions/build-and-push-docker-images#keep-up-to-date-with-github-dependabot
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: 'daily'
|
||||
|
|
|
|||
5
.github/workflows/bats.yml
vendored
5
.github/workflows/bats.yml
vendored
|
|
@ -1,5 +1,8 @@
|
|||
name: Bats
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
|
@ -16,7 +19,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
|
|
|||
57
.github/workflows/docker-image-develop.yml
vendored
57
.github/workflows/docker-image-develop.yml
vendored
|
|
@ -1,29 +1,38 @@
|
|||
name: Build and Push Docker Image for Develop Branch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64 # linux/arm/v7 arm32 is not supported by node20 https://github.com/nodejs/docker-node/issues/1946
|
||||
tags: borgwarehouse/borgwarehouse:develop
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
echo "Current Commit: $COMMIT"
|
||||
jq '.version = "develop-'$COMMIT'"' package.json > package.tmp.json
|
||||
mv package.tmp.json package.json
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64 # linux/arm/v7 arm32 is not supported by node20 https://github.com/nodejs/docker-node/issues/1946
|
||||
tags: borgwarehouse/borgwarehouse:develop
|
||||
|
|
|
|||
4
.github/workflows/docker-image-latest.yml
vendored
4
.github/workflows/docker-image-latest.yml
vendored
|
|
@ -1,4 +1,6 @@
|
|||
name: Build and Push Docker Image
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -10,7 +12,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
|
|
|||
5
.github/workflows/docker-image-release.yml
vendored
5
.github/workflows/docker-image-release.yml
vendored
|
|
@ -5,12 +5,15 @@ on:
|
|||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
|
|
|||
5
.github/workflows/docker-image-test.yml
vendored
5
.github/workflows/docker-image-test.yml
vendored
|
|
@ -1,5 +1,8 @@
|
|||
name: Test to build docker container on Pull Request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
|
|
@ -11,7 +14,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
|
|
|||
12
.github/workflows/shellcheck.yml
vendored
12
.github/workflows/shellcheck.yml
vendored
|
|
@ -4,19 +4,21 @@ on:
|
|||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches: main
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
name: "Shellcheck"
|
||||
name: 'Shellcheck'
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
shellcheck:
|
||||
name: Shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@master
|
||||
env:
|
||||
|
|
|
|||
63
.github/workflows/vitest.yml
vendored
Normal file
63
.github/workflows/vitest.yml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
name: Vitest & ESLint CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Vitest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Vitest
|
||||
run: pnpm run test
|
||||
|
||||
lint:
|
||||
name: Run ESLint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run ESLint
|
||||
run: pnpm exec eslint
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -50,6 +50,14 @@ typings/
|
|||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
pnpm-debug.log*
|
||||
|
||||
# Lock files (pnpm-lock.yaml is used)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
|
|
|
|||
|
|
@ -23,41 +23,43 @@ function checkBreakingChangeInBody() {
|
|||
}
|
||||
|
||||
function findTypeIcon() {
|
||||
# get message from 1st param
|
||||
message="$1"
|
||||
|
||||
# declare an icons for each authorized enum-type from `.commitlintrc.js`
|
||||
declare -A icons
|
||||
icons[build]='🤖'
|
||||
icons[chore]='🧹'
|
||||
icons["chore(deps)"]='🧹'
|
||||
icons[config]='🔧'
|
||||
icons[deploy]='🚀'
|
||||
icons[doc]='📚'
|
||||
icons[feat]='✨'
|
||||
icons[fix]='🐛'
|
||||
icons[hotfix]='🚑'
|
||||
icons[i18n]='💬'
|
||||
icons[publish]='📦'
|
||||
icons[refactor]='⚡'
|
||||
icons[revert]='⏪'
|
||||
icons[test]='✅'
|
||||
icons[ui]='🎨'
|
||||
icons[wip]='🚧'
|
||||
icons[WIP]='🚧'
|
||||
if [[ "$message" =~ ^.*!:\ .* ]]; then
|
||||
echo "$boomIcon"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for type in "${!icons[@]}"; do
|
||||
# check if message subject contains breaking change pattern
|
||||
if [[ "$message" =~ ^(.*)(!:){1}(.*)$ ]]; then
|
||||
echo "$boomIcon"
|
||||
return 0
|
||||
# else find corresponding type icon
|
||||
elif [[ "$message" == "$type"* ]]; then
|
||||
echo "${icons[$type]}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
declare -A icons=(
|
||||
[build]='🤖'
|
||||
[chore]='🧹'
|
||||
["chore(deps)"]='🧹'
|
||||
[config]='🔧'
|
||||
[deploy]='🚀'
|
||||
[doc]='📚'
|
||||
[feat]='✨'
|
||||
[fix]='🐛'
|
||||
[hotfix]='🚑'
|
||||
[i18n]='💬'
|
||||
[publish]='📦'
|
||||
[refactor]='⚡'
|
||||
[revert]='⏪'
|
||||
[test]='✅'
|
||||
[ui]='🎨'
|
||||
[wip]='🚧'
|
||||
[WIP]='🚧'
|
||||
[docker]='🐳'
|
||||
)
|
||||
|
||||
commit_type="${message%%:*}"
|
||||
|
||||
icon="${icons[$commit_type]}"
|
||||
if [[ -n "$icon" ]]; then
|
||||
echo "$icon"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# extract original message from the first line of file
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
#!/bin/bash
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Check if it's an amend commit
|
||||
if [ "$2" = "commit" ]; then
|
||||
echo "Amendment detected, appending icon..."
|
||||
|
|
|
|||
7
.npmrc
Normal file
7
.npmrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Configuration pnpm
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
shamefully-hoist=false
|
||||
|
||||
# Force pnpm usage (prevent npm/yarn)
|
||||
package-manager=pnpm
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
margin: auto 47px auto auto;
|
||||
margin: auto 25px auto auto;
|
||||
}
|
||||
|
||||
.icons {
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #6d4aff21;
|
||||
background-color: #f5f5f5;
|
||||
background-color: #fafafa;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset;
|
||||
color: #65748b;
|
||||
|
|
@ -50,6 +50,7 @@
|
|||
|
||||
.copyValid {
|
||||
margin: auto 8px auto auto;
|
||||
padding: 6px 6px;
|
||||
font-size: 0.95rem;
|
||||
color: #6d4aff;
|
||||
animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
||||
|
|
@ -76,7 +77,7 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #6d4aff21;
|
||||
background-color: #f5f5f5;
|
||||
background-color: #fafafa;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset;
|
||||
color: #65748b;
|
||||
|
|
|
|||
|
|
@ -1,26 +1,30 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import classes from './QuickCommands.module.css';
|
||||
import { IconSettingsAutomation, IconCopy } from '@tabler/icons-react';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
import { WizardEnvType } from '~/types/domain/config.types';
|
||||
|
||||
export default function QuickCommands(props) {
|
||||
////Vars
|
||||
type QuickCommandsProps = {
|
||||
repositoryName: string;
|
||||
wizardEnv?: WizardEnvType;
|
||||
lanCommand?: boolean;
|
||||
};
|
||||
|
||||
export default function QuickCommands(props: QuickCommandsProps) {
|
||||
const wizardEnv = props.wizardEnv;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.lanCommand);
|
||||
|
||||
//State
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
//Functions
|
||||
const handleCopy = async () => {
|
||||
// Asynchronously call copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(`ssh://${wizardEnv.UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.repositoryName}`)
|
||||
.writeText(
|
||||
`ssh://${wizardEnv?.UNIX_USER}@${FQDN}${SSH_SERVER_PORT ? SSH_SERVER_PORT : ''}/./${props.repositoryName}`
|
||||
)
|
||||
.then(() => {
|
||||
// If successful, update the isCopied state value
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
|
|
@ -37,8 +41,8 @@ export default function QuickCommands(props) {
|
|||
<div className={classes.copyValid}>Copied !</div>
|
||||
) : (
|
||||
<div className={classes.tooltip}>
|
||||
ssh://{wizardEnv.UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
ssh://{wizardEnv?.UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT ? SSH_SERVER_PORT : ''}/./
|
||||
{props.repositoryName}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
//Lib
|
||||
import { useState } from 'react';
|
||||
import classes from './Repo.module.css';
|
||||
import {
|
||||
IconSettings,
|
||||
IconInfoCircle,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconBellOff,
|
||||
IconLockPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import timestampConverter from '../../helpers/functions/timestampConverter';
|
||||
import StorageBar from '../UI/StorageBar/StorageBar';
|
||||
import QuickCommands from './QuickCommands/QuickCommands';
|
||||
|
||||
export default function Repo(props) {
|
||||
//Load displayDetails from LocalStorage
|
||||
const displayDetailsFromLS = () => {
|
||||
try {
|
||||
if (localStorage.getItem('displayDetailsRepo' + props.id) === null) {
|
||||
localStorage.setItem('displayDetailsRepo' + props.id, JSON.stringify(true));
|
||||
return true;
|
||||
} else {
|
||||
return JSON.parse(localStorage.getItem('displayDetailsRepo' + props.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'LocalStorage error, key',
|
||||
'displayDetailsRepo' + props.id,
|
||||
'will be removed. Try again.',
|
||||
'Error message on this key : ',
|
||||
error
|
||||
);
|
||||
localStorage.removeItem('displayDetailsRepo' + props.id);
|
||||
}
|
||||
};
|
||||
|
||||
//States
|
||||
const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS);
|
||||
|
||||
//BUTTON : Display or not repo details for ONE repo
|
||||
const displayDetailsForOneHandler = (boolean) => {
|
||||
//Update localStorage
|
||||
localStorage.setItem('displayDetailsRepo' + props.id, JSON.stringify(boolean));
|
||||
setDisplayDetails(boolean);
|
||||
};
|
||||
|
||||
//Status indicator
|
||||
const statusIndicator = () => {
|
||||
return props.status ? classes.statusIndicatorGreen : classes.statusIndicatorRed;
|
||||
};
|
||||
|
||||
//Alert indicator
|
||||
const alertIndicator = () => {
|
||||
if (props.alert === 0) {
|
||||
return (
|
||||
<div className={classes.alertIcon}>
|
||||
<IconBellOff size={16} color='grey' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const appendOnlyModeIndicator = () => {
|
||||
if (props.appendOnlyMode) {
|
||||
return (
|
||||
<div className={classes.appendOnlyModeIcon}>
|
||||
<IconLockPlus size={16} color='grey' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayDetails ? (
|
||||
<>
|
||||
<div className={classes.RepoOpen}>
|
||||
<div className={classes.openFlex}>
|
||||
<div className={statusIndicator()} />
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='grey' />
|
||||
<div className={classes.toolTip}>{props.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
<QuickCommands
|
||||
repositoryName={props.repositoryName}
|
||||
lanCommand={props.lanCommand}
|
||||
wizardEnv={props.wizardEnv}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<table className={classes.tabInfo}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '15%' }}>Repository</th>
|
||||
<th style={{ width: '10%' }}>Storage Size</th>
|
||||
<th style={{ width: '30%' }}>Storage Used</th>
|
||||
<th style={{ width: '15%' }}>Last change</th>
|
||||
<th style={{ width: '5%' }}>ID</th>
|
||||
<th style={{ width: '5%' }}>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{props.repositoryName}</th>
|
||||
<th>{props.storageSize} GB</th>
|
||||
<th style={{ padding: '0 4% 0 4%' }}>
|
||||
<StorageBar storageUsed={props.storageUsed} storageSize={props.storageSize} />
|
||||
</th>
|
||||
<th>
|
||||
<div className={classes.lastSave}>
|
||||
{props.lastSave === 0 ? '-' : timestampConverter(props.lastSave)}
|
||||
</div>
|
||||
</th>
|
||||
<th>#{props.id}</th>
|
||||
<th>
|
||||
<div className={classes.editButton}>
|
||||
<IconSettings
|
||||
width={24}
|
||||
color='#6d4aff'
|
||||
onClick={() => props.repoManageEditHandler()}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={classes.RepoClose}>
|
||||
<div className={classes.closeFlex}>
|
||||
<div className={statusIndicator()} />
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='#637381' />
|
||||
<div className={classes.toolTip}>{props.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.lastSave}>
|
||||
{props.lastSave === 0 ? null : timestampConverter(props.lastSave)}
|
||||
<span style={{ marginLeft: '20px', color: '#637381' }}>#{props.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{displayDetails ? (
|
||||
<div className={classes.chevron}>
|
||||
<IconChevronUp
|
||||
color='#494b7a'
|
||||
size={28}
|
||||
onClick={() => {
|
||||
displayDetailsForOneHandler(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.chevron}>
|
||||
<IconChevronDown
|
||||
color='#494b7a'
|
||||
size={28}
|
||||
onClick={() => {
|
||||
displayDetailsForOneHandler(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,16 +13,39 @@
|
|||
overflow: visible;
|
||||
/* Need to display comment on hover (which is position : absolute) */
|
||||
position: relative;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.closeFlex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.RepoClose .lastSave {
|
||||
padding: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.RepoClose .leftGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.RepoClose .alias {
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
font-size: 1.05em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* REPO OPEN */
|
||||
|
|
@ -35,7 +58,6 @@
|
|||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
width: auto;
|
||||
max-height: 200px;
|
||||
margin: 20px 0px 0px 0px;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
|
|
@ -43,15 +65,29 @@
|
|||
overflow: visible;
|
||||
/* Need to display comment on hover (which is position : absolute) */
|
||||
position: relative;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.openFlex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aliasFlex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.indicatorsFlex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.tabInfo {
|
||||
width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
|
|
@ -59,7 +95,7 @@
|
|||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 25px auto;
|
||||
margin: 15px auto;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +109,7 @@
|
|||
font-size: 1em;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
font-weight: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tabInfo tbody tr {
|
||||
|
|
@ -88,80 +124,52 @@
|
|||
}
|
||||
|
||||
/*STATUS*/
|
||||
|
||||
.statusIndicatorGreen {
|
||||
background: rgb(9, 255, 0);
|
||||
.statusIndicatorGreen,
|
||||
.statusIndicatorRed {
|
||||
border-radius: 50%;
|
||||
margin: 10px;
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
box-shadow: 0 0 0 0 rgb(9, 255, 0);
|
||||
transform: scale(1);
|
||||
animation: pulseGreen 5s infinite;
|
||||
animation-delay: 1s;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
animation: pulse 5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGreen {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(17, 255, 0, 0.7);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(17, 255, 0, 0);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(17, 255, 0, 0);
|
||||
}
|
||||
.statusIndicatorGreen {
|
||||
background: #00d26a;
|
||||
box-shadow: 0 0 0 0 rgba(0, 210, 106, 0.7);
|
||||
}
|
||||
|
||||
.statusIndicatorRed {
|
||||
background: rgb(255, 0, 0);
|
||||
border-radius: 50%;
|
||||
margin: 10px;
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
|
||||
box-shadow: 0 0 0 0 rgb(255, 0, 0);
|
||||
transform: scale(1);
|
||||
animation: pulseRed 5s infinite;
|
||||
background: #ff3d3d;
|
||||
box-shadow: 0 0 0 0 rgba(255, 61, 61, 0.7);
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
@keyframes pulseRed {
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(255, 0, 0, 0);
|
||||
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 0, 0, 0);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert icon */
|
||||
|
||||
.alertIcon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.appendOnlyModeIcon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* GENERAL */
|
||||
|
|
@ -169,6 +177,13 @@
|
|||
font-weight: bold;
|
||||
color: #111827;
|
||||
font-size: 1.05em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.RepoOpen .alias {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.lastSave {
|
||||
|
|
@ -184,7 +199,6 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.toolTip {
|
||||
|
|
@ -227,23 +241,69 @@
|
|||
|
||||
/* MOBILE */
|
||||
@media all and (max-width: 1000px) {
|
||||
.tabInfo {
|
||||
display: none;
|
||||
.openFlex,
|
||||
.tabInfo,
|
||||
.toolTip,
|
||||
.comment,
|
||||
.chevron {
|
||||
display: none !important;
|
||||
}
|
||||
.toolTip {
|
||||
display: none;
|
||||
}
|
||||
.comment {
|
||||
display: none;
|
||||
}
|
||||
.lastSave {
|
||||
display: none;
|
||||
|
||||
.RepoOpen,
|
||||
.RepoClose {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
max-height: 65px !important;
|
||||
padding: 15px !important;
|
||||
margin: 20px 0 0 0 !important;
|
||||
}
|
||||
|
||||
.closeFlex {
|
||||
margin: auto;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.openFlex {
|
||||
margin: auto;
|
||||
width: auto;
|
||||
|
||||
.alias {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.leftGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rightGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lastSave {
|
||||
display: block !important;
|
||||
color: #65748b;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
flex-shrink: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.appendOnlyModeIcon,
|
||||
.alertIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
261
Components/Repo/Repo.tsx
Normal file
261
Components/Repo/Repo.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import classes from './Repo.module.css';
|
||||
import {
|
||||
IconSettings,
|
||||
IconInfoCircle,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconBellOff,
|
||||
IconLockPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import StorageBar from '../UI/StorageBar/StorageBar';
|
||||
import QuickCommands from './QuickCommands/QuickCommands';
|
||||
import { Repository, WizardEnvType, Optional } from '~/types';
|
||||
import { fromUnixTime, formatDistanceStrict } from 'date-fns';
|
||||
import useMedia from 'use-media';
|
||||
|
||||
type RepoProps = Omit<Repository, 'unixUser' | 'displayDetails'> & {
|
||||
repoManageEditHandler: () => void;
|
||||
wizardEnv: Optional<WizardEnvType>;
|
||||
};
|
||||
|
||||
export default function Repo(props: RepoProps) {
|
||||
const isMobile = useMedia({ maxWidth: 1000 });
|
||||
|
||||
const currentDate = useMemo(() => new Date(), []);
|
||||
|
||||
//Load displayDetails from LocalStorage
|
||||
const displayDetailsFromLS = (): boolean => {
|
||||
const key = `displayDetailsRepo${props.id}`;
|
||||
|
||||
try {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
|
||||
if (storedValue === null) {
|
||||
const defaultValue = true;
|
||||
localStorage.setItem(key, JSON.stringify(defaultValue));
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsedValue = JSON.parse(storedValue);
|
||||
if (typeof parsedValue === 'boolean') {
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
//States
|
||||
const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS);
|
||||
|
||||
//BUTTON : Display or not repo details for ONE repo
|
||||
const displayDetailsForOneHandler = (boolean: boolean) => {
|
||||
//Update localStorage
|
||||
localStorage.setItem('displayDetailsRepo' + props.id, JSON.stringify(boolean));
|
||||
setDisplayDetails(boolean);
|
||||
};
|
||||
|
||||
//Status indicator
|
||||
const statusIndicator = () => {
|
||||
return props.status ? classes.statusIndicatorGreen : classes.statusIndicatorRed;
|
||||
};
|
||||
|
||||
//Alert indicator
|
||||
const alertIndicator = () => {
|
||||
if (props.alert === 0) {
|
||||
return (
|
||||
<div className={classes.alertIcon}>
|
||||
<IconBellOff size={16} color='grey' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const appendOnlyModeIndicator = () => {
|
||||
if (props.appendOnlyMode) {
|
||||
return (
|
||||
<div className={classes.appendOnlyModeIcon}>
|
||||
<IconLockPlus size={16} color='grey' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const mobileView = () => {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.RepoClose}>
|
||||
<div className={classes.closeFlex}>
|
||||
<div className={classes.leftGroup}>
|
||||
<div className={statusIndicator()} />
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
</div>
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='#637381' />
|
||||
<div className={classes.toolTip}>{props.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.lastSave}>
|
||||
<span
|
||||
title={
|
||||
props.lastSave === 0 ? undefined : fromUnixTime(props.lastSave).toLocaleString()
|
||||
}
|
||||
>
|
||||
{props.lastSave === 0
|
||||
? '-'
|
||||
: formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return mobileView();
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{displayDetails ? (
|
||||
<>
|
||||
<div className={classes.RepoOpen}>
|
||||
<div className={classes.indicatorsFlex}>
|
||||
<div className={statusIndicator()} />
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='grey' />
|
||||
<div className={classes.toolTip}>{props.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
<QuickCommands
|
||||
repositoryName={props.repositoryName}
|
||||
lanCommand={props.lanCommand}
|
||||
wizardEnv={props.wizardEnv}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.aliasFlex}>
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
</div>
|
||||
|
||||
<table className={classes.tabInfo}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '15%' }}>Repository</th>
|
||||
<th style={{ width: '10%' }}>Storage Size</th>
|
||||
<th style={{ width: '30%' }}>Storage Used</th>
|
||||
<th style={{ width: '15%' }}>Last change</th>
|
||||
<th style={{ width: '10%' }}>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{props.repositoryName}</th>
|
||||
<th>{props.storageSize} GB</th>
|
||||
<th style={{ padding: '0 4% 0 4%' }}>
|
||||
<StorageBar storageUsed={props.storageUsed} storageSize={props.storageSize} />
|
||||
</th>
|
||||
<th>
|
||||
<div
|
||||
className={classes.lastSave}
|
||||
title={
|
||||
props.lastSave === 0
|
||||
? undefined
|
||||
: fromUnixTime(props.lastSave).toLocaleString()
|
||||
}
|
||||
>
|
||||
{props.lastSave === 0
|
||||
? '-'
|
||||
: formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div className={classes.editButton}>
|
||||
<IconSettings
|
||||
width={24}
|
||||
color='#6d4aff'
|
||||
onClick={() => props.repoManageEditHandler()}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={classes.RepoClose}>
|
||||
<div className={classes.closeFlex}>
|
||||
<div className={classes.leftGroup}>
|
||||
<div className={statusIndicator()} />
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
</div>
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='#637381' />
|
||||
<div className={classes.toolTip}>{props.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.lastSave}>
|
||||
<span
|
||||
title={
|
||||
props.lastSave === 0
|
||||
? undefined
|
||||
: fromUnixTime(props.lastSave).toLocaleString()
|
||||
}
|
||||
>
|
||||
{props.lastSave === 0
|
||||
? '-'
|
||||
: formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{displayDetails ? (
|
||||
<div className={classes.chevron}>
|
||||
<IconChevronUp
|
||||
color='#494b7a'
|
||||
size={28}
|
||||
onClick={() => {
|
||||
displayDetailsForOneHandler(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.chevron}>
|
||||
<IconChevronDown
|
||||
color='#494b7a'
|
||||
size={28}
|
||||
onClick={() => {
|
||||
displayDetailsForOneHandler(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
//Lib
|
||||
import classes from './CopyButton.module.css';
|
||||
import { useState } from 'react';
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { IconChecks, IconCopy } from '@tabler/icons-react';
|
||||
|
||||
export default function CopyButton(props) {
|
||||
//State
|
||||
type CopyButtonProps = {
|
||||
dataToCopy: string;
|
||||
children?: ReactNode;
|
||||
displayIconConfirmation?: boolean;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
};
|
||||
|
||||
export default function CopyButton(props: CopyButtonProps) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
//Function
|
||||
const handleCopy = async (data) => {
|
||||
const handleCopy = async (data: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(data)
|
||||
.then(() => {
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
//Lib
|
||||
import classes from './Error.module.css';
|
||||
|
||||
export default function Error(props) {
|
||||
type ErrorProps = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export default function Error(props: ErrorProps) {
|
||||
return <div className={classes.errorMessage}>{props.message}</div>;
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
//Lib
|
||||
import { ReactNode } from 'react';
|
||||
import classes from './Info.module.css';
|
||||
|
||||
export default function Info(props) {
|
||||
type InfoProps = {
|
||||
message: string;
|
||||
color?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Info(props: InfoProps) {
|
||||
return (
|
||||
<div className={classes.infoMessage} style={{ backgroundColor: props.color }}>
|
||||
{props.message}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
//Lib
|
||||
import classes from './Footer.module.css';
|
||||
import packageInfo from '../../../../package.json';
|
||||
import packageInfo from '~/package.json';
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
|
|
@ -27,5 +27,5 @@
|
|||
font-weight: bold;
|
||||
color: #6d4aff;
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
margin-left: 20px;
|
||||
margin-left: 70px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
//Lib
|
||||
import Image from 'next/image';
|
||||
import classes from './Header.module.css';
|
||||
|
||||
//Components
|
||||
import Nav from './Nav/Nav';
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<header className={classes.Header}>
|
||||
<div className={[classes.flex, 'container'].join(' ')}>
|
||||
<div className={classes.logo}>BorgWarehouse</div>
|
||||
<div className={classes.logo}>
|
||||
<Image
|
||||
src='/borgwarehouse-logo-violet.svg'
|
||||
alt='BorgWarehouse'
|
||||
width={225}
|
||||
height={40}
|
||||
className={classes.logoImage}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<Nav />
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
//Lib
|
||||
import classes from './Nav.module.css';
|
||||
import { IconUser, IconLogout } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
|
|
@ -6,13 +5,10 @@ import { useRouter } from 'next/router';
|
|||
import { useSession, signOut } from 'next-auth/react';
|
||||
|
||||
export default function Nav() {
|
||||
////Var
|
||||
//Get the current route to light the right Item
|
||||
const router = useRouter();
|
||||
const currentRoute = router.pathname;
|
||||
const { status, data } = useSession();
|
||||
|
||||
//Function
|
||||
const onLogoutClickedHandler = async () => {
|
||||
//This bug is open : https://github.com/nextauthjs/next-auth/issues/1542
|
||||
//I put redirect to false and redirect with router.
|
||||
|
|
@ -25,12 +21,12 @@ export default function Nav() {
|
|||
return (
|
||||
<ul className={classes.Nav}>
|
||||
<li style={{ margin: '0px 15px 0px 0px' }} className={classes.account}>
|
||||
<Link href='/account' className={currentRoute === '/account' ? classes.active : null}>
|
||||
<Link href='/account' className={currentRoute === '/account' ? classes.active : undefined}>
|
||||
<div className={classes.user}>
|
||||
<div>
|
||||
<IconUser size={28} />
|
||||
</div>
|
||||
<div className={classes.username}>{status === 'authenticated' && data.user.name}</div>
|
||||
<div className={classes.username}>{status === 'authenticated' && data.user?.name}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
//Lib
|
||||
import Footer from './Footer/Footer';
|
||||
import Header from './Header/Header';
|
||||
import NavSide from './NavSide/NavSide';
|
||||
import classes from './Layout.module.css';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
function Layout(props) {
|
||||
//Var
|
||||
type LayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Layout(props: LayoutProps) {
|
||||
const { status } = useSession();
|
||||
|
||||
if (status === 'authenticated') {
|
||||
|
|
@ -1,21 +1,16 @@
|
|||
//Lib
|
||||
import classes from './NavSide.module.css';
|
||||
import { IconServer, IconSettingsAutomation, IconActivityHeartbeat } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
//Composants
|
||||
|
||||
export default function NavSide() {
|
||||
////Var
|
||||
//Get the current route to light the right Item
|
||||
const router = useRouter();
|
||||
const currentRoute = router.pathname;
|
||||
|
||||
return (
|
||||
<ul className={classes.NavSide}>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link href='/' className={currentRoute === '/' ? classes.active : null}>
|
||||
<Link href='/' className={currentRoute === '/' ? classes.active : undefined}>
|
||||
<IconServer size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Repositories</span>
|
||||
|
|
@ -23,14 +18,17 @@ export default function NavSide() {
|
|||
<li className={classes.NavSideItem}>
|
||||
<Link
|
||||
href='/setup-wizard/1'
|
||||
className={currentRoute === '/setup-wizard/[slug]' ? classes.active : null}
|
||||
className={currentRoute === '/setup-wizard/[slug]' ? classes.active : undefined}
|
||||
>
|
||||
<IconSettingsAutomation size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Setup Wizard</span>
|
||||
</li>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link href='/monitoring' className={currentRoute === '/monitoring' ? classes.active : null}>
|
||||
<Link
|
||||
href='/monitoring'
|
||||
className={currentRoute === '/monitoring' ? classes.active : undefined}
|
||||
>
|
||||
<IconActivityHeartbeat size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Monitoring</span>
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
//Lib
|
||||
import classes from './ShimmerRepoList.module.css';
|
||||
|
||||
const LOADING_REPO_COUNT = 5;
|
||||
|
||||
function ShimmerRepoItem() {
|
||||
return <div className={classes.repoIsLoading} />;
|
||||
}
|
||||
|
||||
export default function ShimmerRepoList() {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
|
|
@ -8,11 +13,9 @@ export default function ShimmerRepoList() {
|
|||
<div className={classes.buttonIsLoading} />
|
||||
</div>
|
||||
<div className={classes.loadingRepoContainer}>
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
{Array.from({ length: LOADING_REPO_COUNT }, (_, i) => (
|
||||
<ShimmerRepoItem key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
//Lib
|
||||
import classes from './StorageBar.module.css';
|
||||
|
||||
export default function StorageBar(props) {
|
||||
//Var
|
||||
type StorageBarProps = {
|
||||
storageUsed: number;
|
||||
storageSize: number;
|
||||
};
|
||||
|
||||
export default function StorageBar(props: StorageBarProps) {
|
||||
//storageUsed is in kB, storageSize is in GB. Round to 1 decimal for %.
|
||||
const storageUsedPercent = (((props.storageUsed / 1024 ** 2) * 100) / props.storageSize).toFixed(
|
||||
1
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
//Lib
|
||||
import classes from './Switch.module.css';
|
||||
|
||||
export default function Switch(props) {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.switchWrapper}>
|
||||
<div className={classes.switch}>
|
||||
<label className={classes.pureMaterialSwitch}>
|
||||
<input
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
type='checkbox'
|
||||
onChange={(e) => props.onChange(e.target.checked)}
|
||||
/>
|
||||
|
||||
<span>{props.switchName}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className={classes.switchDescription}>
|
||||
<span>{props.switchDescription}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,157 +1,84 @@
|
|||
/* Wrapper styles */
|
||||
.switchWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Switch container */
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.switchDescription {
|
||||
display: flex;
|
||||
margin: 8px 0px 0px 0px;
|
||||
color: #6c737f;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch {
|
||||
z-index: 0;
|
||||
/* Label */
|
||||
.switchLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.87);
|
||||
font-family: var(
|
||||
--pure-material-font,
|
||||
'Roboto',
|
||||
'Segoe UI',
|
||||
BlinkMacSystemFont,
|
||||
system-ui,
|
||||
-apple-system
|
||||
);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.pureMaterialSwitch > input {
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: -8px;
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
.switchLabel input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.switchSlider {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.3s 0.1s,
|
||||
transform 0.2s 0.1s;
|
||||
height: 20px;
|
||||
background: #ccc;
|
||||
border-radius: 12px;
|
||||
transition: #ccc 0.3s ease;
|
||||
}
|
||||
|
||||
/* Span */
|
||||
.pureMaterialSwitch > span {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.pureMaterialSwitch > span::before {
|
||||
content: '';
|
||||
float: right;
|
||||
display: inline-block;
|
||||
margin: 5px 0 5px 30px;
|
||||
border-radius: 7px;
|
||||
width: 36px;
|
||||
height: 14px;
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
vertical-align: top;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.pureMaterialSwitch > span::after {
|
||||
.switchSlider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 16px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: rgb(var(--pure-material-onprimary-rgb, 255, 255, 255));
|
||||
box-shadow:
|
||||
0 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0 2px 2px 0 rgba(0, 0, 0, 0.14),
|
||||
0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.2s;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Checked */
|
||||
.pureMaterialSwitch > input:checked {
|
||||
right: -10px;
|
||||
background-color: rgb(var(--pure-material-primary-rgb, 109, 74, 255));
|
||||
/* Checked styles */
|
||||
.switchLabel input:checked + .switchSlider {
|
||||
background: #704dff;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:checked + span::before {
|
||||
background-color: rgba(var(--pure-material-primary-rgb, 109, 74, 255), 0.6);
|
||||
.switchLabel input:checked + .switchSlider::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:checked + span::after {
|
||||
background-color: rgb(var(--pure-material-primary-rgb, 109, 74, 255));
|
||||
transform: translateX(16px);
|
||||
/* Disabled styles */
|
||||
.switchLabel input:disabled + .switchSlider {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Active */
|
||||
.pureMaterialSwitch > input:active {
|
||||
opacity: 1;
|
||||
transform: scale(0);
|
||||
transition:
|
||||
transform 0s,
|
||||
opacity 0s;
|
||||
.switchLabel input:disabled + .switchSlider::after {
|
||||
background: #bdbdbd;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:active + span::before {
|
||||
background-color: rgba(var(--pure-material-primary-rgb, 109, 74, 255), 0.6);
|
||||
/* Switch text */
|
||||
.switchText {
|
||||
font-size: 1rem;
|
||||
color: #494b7a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:checked:active + span::before {
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
/* Description */
|
||||
.switchDescription {
|
||||
font-size: 0.875rem;
|
||||
color: #6c737f;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Disabled */
|
||||
.pureMaterialSwitch > input:disabled + span {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* .pureMaterialSwitch > input:disabled {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:disabled + span {
|
||||
color: rgb(var(--pure-material-onsurface-rgb, 0, 0, 0));
|
||||
opacity: 0.38;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:disabled + span::before {
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:checked:disabled + span::before {
|
||||
background-color: rgba(var(--pure-material-primary-rgb, 109, 74, 255), 0.6);
|
||||
} */
|
||||
|
|
|
|||
45
Components/UI/Switch/Switch.tsx
Normal file
45
Components/UI/Switch/Switch.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Optional } from '~/types';
|
||||
import classes from './Switch.module.css';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type SwitchProps = {
|
||||
switchName: string;
|
||||
switchDescription: string;
|
||||
checked: Optional<boolean>;
|
||||
disabled: boolean;
|
||||
loading?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.loading) {
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}, [props.loading, start, stop]);
|
||||
|
||||
return (
|
||||
<div className={classes.switchWrapper}>
|
||||
<div className={classes.switch}>
|
||||
<label className={classes.switchLabel}>
|
||||
<>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={props.checked || false}
|
||||
disabled={props.disabled}
|
||||
onChange={(e) => props.onChange(e.target.checked)}
|
||||
/>
|
||||
<span className={classes.switchSlider}></span>
|
||||
</>
|
||||
<span className={classes.switchText}>{props.switchName}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className={classes.switchDescription}>{props.switchDescription}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconDeviceDesktopAnalytics, IconTerminal2 } from '@tabler/icons-react';
|
||||
|
|
@ -49,15 +48,7 @@ function WizardStep1() {
|
|||
Vorta
|
||||
</a>
|
||||
.
|
||||
<br />
|
||||
Vorta runs on Linux, MacOS and Windows (via Windows’ Linux Subsystem (WSL)). Find the right
|
||||
way to install it{' '}
|
||||
<a href='https://vorta.borgbase.com/install/' target='_blank' rel='noreferrer'>
|
||||
just here
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<img src='/vorta-demo.gif' alt='Vorta GIF' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconTool, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { IconAlertCircle, IconTool } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
import { WizardStepProps } from '~/types';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
|
||||
function WizardStep2(props) {
|
||||
////Vars
|
||||
function WizardStep2(props: WizardStepProps) {
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv.UNIX_USER;
|
||||
const UNIX_USER = wizardEnv?.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedOption.lanCommand);
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedRepo?.lanCommand);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
|
|
@ -32,10 +30,10 @@ function WizardStep2(props) {
|
|||
borg init -e repokey-blake2 ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
{props.selectedRepo?.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg init -e repokey-blake2 ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
dataToCopy={`borg init -e repokey-blake2 ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.note}>
|
||||
|
|
@ -71,8 +69,8 @@ function WizardStep2(props) {
|
|||
|
||||
<h2>Pika, Vorta...</h2>
|
||||
<div className={classes.description}>
|
||||
To "Initialize a new repository" or "Add existing repository", copy this into the field
|
||||
"Repository URL" of your graphical client :
|
||||
To "Initialize a new repository" or "Add existing repository", copy this
|
||||
into the field "Repository URL" of your graphical client :
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -85,10 +83,10 @@ function WizardStep2(props) {
|
|||
ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
{props.selectedRepo?.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
dataToCopy={`ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -100,19 +98,21 @@ function WizardStep2(props) {
|
|||
<b>Check the fingerprint of server</b>
|
||||
</div>
|
||||
To check that you are talking to the right server, please make sure to validate one of the
|
||||
following key's fingerprint when you first connect :
|
||||
following key's fingerprint when you first connect :
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>
|
||||
ECDSA : {wizardEnv.SSH_SERVER_FINGERPRINT_ECDSA}
|
||||
ECDSA : {wizardEnv?.SSH_SERVER_FINGERPRINT_ECDSA}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>
|
||||
ED25519 : {wizardEnv.SSH_SERVER_FINGERPRINT_ED25519}
|
||||
ED25519 : {wizardEnv?.SSH_SERVER_FINGERPRINT_ED25519}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>RSA : {wizardEnv.SSH_SERVER_FINGERPRINT_RSA}</span>
|
||||
<span className={classes.sshPublicKey}>
|
||||
RSA : {wizardEnv?.SSH_SERVER_FINGERPRINT_RSA}
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconChecks, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
import { WizardStepProps } from '~/types';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
|
||||
function WizardStep3(props) {
|
||||
////Vars
|
||||
function WizardStep3(props: WizardStepProps) {
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv.UNIX_USER;
|
||||
const UNIX_USER = wizardEnv?.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedOption.lanCommand);
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedRepo?.lanCommand);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
|
|
@ -31,11 +30,11 @@ function WizardStep3(props) {
|
|||
borg create ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
{props.selectedRepo?.repositoryName}
|
||||
::archive1 /your/pathToBackup
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg create ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 /your/pathToBackup`}
|
||||
dataToCopy={`borg create ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 /your/pathToBackup`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,10 +69,10 @@ function WizardStep3(props) {
|
|||
borg check -v --progress ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
{props.selectedRepo?.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg check -v --progress ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
dataToCopy={`borg check -v --progress ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<li>List the remote archives with :</li>
|
||||
|
|
@ -88,10 +87,10 @@ function WizardStep3(props) {
|
|||
borg list ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
{props.selectedRepo?.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg list ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
dataToCopy={`borg list ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<li>Download a remote archive with the following command :</li>
|
||||
|
|
@ -103,14 +102,14 @@ function WizardStep3(props) {
|
|||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg export-tar --tar-filter="gzip -9" ssh://
|
||||
borg export-tar --tar-filter="gzip -9" ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
{props.selectedRepo?.repositoryName}
|
||||
::archive1 archive1.tar.gz
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg export-tar --tar-filter="gzip -9" ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 archive1.tar.gz`}
|
||||
dataToCopy={`borg export-tar --tar-filter="gzip -9" ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 archive1.tar.gz`}
|
||||
/>
|
||||
</div>
|
||||
<li>Mount an archive to compare or backup some files without download all the archive :</li>
|
||||
|
|
@ -125,11 +124,11 @@ function WizardStep3(props) {
|
|||
borg mount ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
{props.selectedRepo?.repositoryName}
|
||||
::archive1 /tmp/yourMountPoint
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg mount ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 /tmp/yourMountPoint`}
|
||||
dataToCopy={`borg mount ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 /tmp/yourMountPoint`}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconWand } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
import { WizardStepProps } from '~/types';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
|
||||
function WizardStep4(props) {
|
||||
////Vars
|
||||
function WizardStep4(props: WizardStepProps) {
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv.UNIX_USER;
|
||||
const UNIX_USER = wizardEnv?.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedOption.lanCommand);
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedRepo?.lanCommand);
|
||||
|
||||
const configBorgmatic = `location:
|
||||
const configBorgmatic = `
|
||||
# List of source directories to backup.
|
||||
source_directories:
|
||||
- /your-repo-to-backup
|
||||
|
|
@ -20,24 +19,21 @@ function WizardStep4(props) {
|
|||
|
||||
repositories:
|
||||
# Paths of local or remote repositories to backup to.
|
||||
- ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}
|
||||
- path: ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}
|
||||
|
||||
storage:
|
||||
archive_name_format: '{FQDN}-documents-{now}'
|
||||
encryption_passphrase: "YOUR PASSPHRASE"
|
||||
archive_name_format: '{FQDN}-documents-{now}'
|
||||
encryption_passphrase: "YOUR PASSPHRASE"
|
||||
|
||||
retention:
|
||||
# Retention policy for how many backups to keep.
|
||||
keep_daily: 7
|
||||
keep_weekly: 4
|
||||
keep_monthly: 6
|
||||
# Retention policy for how many backups to keep.
|
||||
keep_daily: 7
|
||||
keep_weekly: 4
|
||||
keep_monthly: 6
|
||||
|
||||
consistency:
|
||||
# List of checks to run to validate your backups.
|
||||
checks:
|
||||
- name: repository
|
||||
- name: archives
|
||||
frequency: 2 weeks
|
||||
# List of checks to run to validate your backups.
|
||||
checks:
|
||||
- name: repository
|
||||
- name: archives
|
||||
frequency: 2 weeks
|
||||
|
||||
#hooks:
|
||||
# Custom preparation scripts to run.
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from './WizardStepBar.module.css';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
function WizardStepBar(props) {
|
||||
////Functions
|
||||
type WizardStepBarProps = {
|
||||
step: number;
|
||||
setStep: (step: number) => void;
|
||||
previousStepHandler: () => void;
|
||||
nextStepHandler: () => void;
|
||||
};
|
||||
|
||||
function WizardStepBar(props: WizardStepBarProps) {
|
||||
//Color onClick on a step
|
||||
const colorHandler = (step) => {
|
||||
const colorHandler = (step: number) => {
|
||||
if (step <= props.step) {
|
||||
return classes.active;
|
||||
} else {
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
//Lib
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
|
|
@ -10,16 +9,15 @@ import {
|
|||
} from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Repository, Optional } from '~/types';
|
||||
|
||||
export default function StorageUsedChartBar() {
|
||||
//States
|
||||
const [data, setData] = useState([]);
|
||||
const [data, setData] = useState<Optional<Array<Repository>>>();
|
||||
|
||||
//LifeCycle
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/repo', {
|
||||
const response = await fetch('/api/v1/repositories', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -41,10 +39,10 @@ export default function StorageUsedChartBar() {
|
|||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
position: 'bottom' as const,
|
||||
},
|
||||
title: {
|
||||
position: 'bottom',
|
||||
position: 'bottom' as const,
|
||||
display: true,
|
||||
text: 'Storage used for each repository',
|
||||
},
|
||||
|
|
@ -55,7 +53,7 @@ export default function StorageUsedChartBar() {
|
|||
min: 0,
|
||||
ticks: {
|
||||
// Include a dollar sign in the ticks
|
||||
callback: function (value) {
|
||||
callback: function (value: number | string) {
|
||||
return value + '%';
|
||||
},
|
||||
stepSize: 10,
|
||||
|
|
@ -64,7 +62,7 @@ export default function StorageUsedChartBar() {
|
|||
},
|
||||
};
|
||||
|
||||
const labels = data.map((repo) => repo.alias);
|
||||
const labels = data?.map((repo) => repo.alias);
|
||||
|
||||
const dataChart = {
|
||||
labels,
|
||||
|
|
@ -72,7 +70,7 @@ export default function StorageUsedChartBar() {
|
|||
{
|
||||
label: 'Storage used (%)',
|
||||
//storageUsed is in kB, storageSize is in GB. Round to 1 decimal for %.
|
||||
data: data.map((repo) =>
|
||||
data: data?.map((repo) =>
|
||||
(((repo.storageUsed / 1024 ** 2) * 100) / repo.storageSize).toFixed(1)
|
||||
),
|
||||
backgroundColor: '#704dff',
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
//Lib
|
||||
import classes from './RepoList.module.css';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
//Composants
|
||||
import Repo from '../../Components/Repo/Repo';
|
||||
import RepoManage from '../RepoManage/RepoManage';
|
||||
import ShimmerRepoList from '../../Components/UI/ShimmerRepoList/ShimmerRepoList';
|
||||
|
||||
export default function RepoList() {
|
||||
////Var
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
////Datas
|
||||
//Write a fetcher function to wrap the native fetch function and return the result of a call to url in json format
|
||||
const fetcher = async (url) => await fetch(url).then((res) => res.json());
|
||||
const { data, error } = useSWR('/api/repo', fetcher);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//If the route is home/manage-repo/add, open the RepoAdd box.
|
||||
if (router.pathname === '/manage-repo/add') {
|
||||
setDisplayRepoAdd(!displayRepoAdd);
|
||||
}
|
||||
//If the route is home/manage-repo/edit, open the RepoAdd box.
|
||||
if (router.pathname.startsWith('/manage-repo/edit')) {
|
||||
setDisplayRepoEdit(!displayRepoEdit);
|
||||
}
|
||||
//Fetch wizardEnv to hydrate Repo components
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getWizardEnv', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setWizardEnv((await response.json()).wizardEnv);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
|
||||
////States
|
||||
const [displayRepoAdd, setDisplayRepoAdd] = useState(false);
|
||||
const [displayRepoEdit, setDisplayRepoEdit] = useState(false);
|
||||
const [wizardEnv, setWizardEnv] = useState({});
|
||||
|
||||
////Functions
|
||||
|
||||
//Firstly, check the availability of data and condition it.
|
||||
if (!data) {
|
||||
//Force mutate after login (force a API GET on /api/repo to load repoList)
|
||||
mutate('/api/repo');
|
||||
return <ShimmerRepoList />;
|
||||
}
|
||||
if (error) {
|
||||
toast.error('An error has occurred.', toastOptions);
|
||||
return <ToastContainer />;
|
||||
}
|
||||
if (data.status == 500) {
|
||||
toast.error('API Error !', toastOptions);
|
||||
return <ToastContainer />;
|
||||
}
|
||||
|
||||
//BUTTON : Display RepoManage component box for ADD
|
||||
const manageRepoAddHandler = () => {
|
||||
router.replace('/manage-repo/add');
|
||||
};
|
||||
|
||||
//BUTTON : Display RepoManage component box for EDIT
|
||||
const repoManageEditHandler = (id) => {
|
||||
router.replace('/manage-repo/edit/' + id);
|
||||
};
|
||||
|
||||
//BUTTON : Close RepoManage component box (when cross is clicked)
|
||||
const closeRepoManageBoxHandler = () => {
|
||||
router.replace('/');
|
||||
};
|
||||
|
||||
// UI EFFECT : Display blur when display add repo modale
|
||||
const displayBlur = () => {
|
||||
if (displayRepoAdd || displayRepoEdit) {
|
||||
return classes.containerBlur;
|
||||
} else {
|
||||
return classes.container;
|
||||
}
|
||||
};
|
||||
|
||||
//Dynamic list of repositories (with a map of Repo components)
|
||||
const renderRepoList = data.repoList.map((repo, index) => {
|
||||
return (
|
||||
<React.Fragment key={repo.id}>
|
||||
<Repo
|
||||
key={repo.id}
|
||||
id={repo.id}
|
||||
alias={repo.alias}
|
||||
status={repo.status}
|
||||
lastSave={repo.lastSave}
|
||||
alert={repo.alert}
|
||||
repositoryName={repo.repositoryName}
|
||||
storageSize={repo.storageSize}
|
||||
storageUsed={repo.storageUsed}
|
||||
sshPublicKey={repo.sshPublicKey}
|
||||
comment={repo.comment}
|
||||
lanCommand={repo.lanCommand}
|
||||
appendOnlyMode={repo.appendOnlyMode}
|
||||
repoManageEditHandler={() => repoManageEditHandler(repo.id)}
|
||||
wizardEnv={wizardEnv}
|
||||
></Repo>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={displayBlur()}>
|
||||
<div className={classes.containerAddRepo}>
|
||||
<Link
|
||||
href='/manage-repo/add'
|
||||
className={classes.newRepoButton}
|
||||
onClick={manageRepoAddHandler}
|
||||
>
|
||||
<IconPlus className={classes.plusIcon} size={24} stroke={2} />
|
||||
<span>Add a repository</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.containerRepoList}>
|
||||
<div className={classes.RepoList}>{renderRepoList}</div>
|
||||
</div>
|
||||
</div>
|
||||
{displayRepoAdd ? (
|
||||
<RepoManage mode='add' repoList={data.repoList} closeHandler={closeRepoManageBoxHandler} />
|
||||
) : null}
|
||||
{displayRepoEdit ? (
|
||||
<RepoManage mode='edit' repoList={data.repoList} closeHandler={closeRepoManageBoxHandler} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -86,7 +86,6 @@
|
|||
flex-direction: column;
|
||||
width: 90%;
|
||||
margin: 5px auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.unfoldButton {
|
||||
|
|
@ -123,3 +122,77 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 90%;
|
||||
margin: 20px auto 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.sortIcons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
color: #a6a6b8;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
transform: scale(1.1);
|
||||
color: #6d4aff;
|
||||
}
|
||||
|
||||
.iconActive {
|
||||
color: #6d4aff;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 8px 32px 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ccc;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
|
|
|||
279
Containers/RepoList/RepoList.tsx
Normal file
279
Containers/RepoList/RepoList.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import classes from './RepoList.module.css';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSortAscendingLetters,
|
||||
IconSortDescendingLetters,
|
||||
IconSortAscending2,
|
||||
IconSortDescending2,
|
||||
IconDatabase,
|
||||
IconX,
|
||||
IconClock,
|
||||
IconCalendarUp,
|
||||
IconCalendarDown,
|
||||
IconSortAscendingSmallBig,
|
||||
IconSortDescendingSmallBig,
|
||||
IconSortDescending2Filled,
|
||||
} from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { ToastContainer, ToastOptions, toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import Repo from '~/Components/Repo/Repo';
|
||||
import RepoManage from '../RepoManage/RepoManage';
|
||||
import ShimmerRepoList from '~/Components/UI/ShimmerRepoList/ShimmerRepoList';
|
||||
import { Repository, WizardEnvType, Optional } from '~/types';
|
||||
|
||||
type SortOption =
|
||||
| 'alias-asc'
|
||||
| 'alias-desc'
|
||||
| 'status-true'
|
||||
| 'status-false'
|
||||
| 'storage-used-asc'
|
||||
| 'storage-used-desc'
|
||||
| 'last-save-asc'
|
||||
| 'last-save-desc';
|
||||
|
||||
export default function RepoList() {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [displayRepoAdd, setDisplayRepoAdd] = useState(false);
|
||||
const [displayRepoEdit, setDisplayRepoEdit] = useState(false);
|
||||
const [wizardEnv, setWizardEnv] = useState<Optional<WizardEnvType>>();
|
||||
|
||||
const [sortOption, setSortOption] = useState<SortOption>(() => {
|
||||
const savedSort = localStorage.getItem('repoSort');
|
||||
return (savedSort as SortOption) || 'alias-asc';
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => {
|
||||
const savedSearch = localStorage.getItem('repoSearch');
|
||||
return savedSearch || '';
|
||||
});
|
||||
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDisplayRepoAdd(router.pathname === '/manage-repo/add');
|
||||
setDisplayRepoEdit(router.pathname.startsWith('/manage-repo/edit'));
|
||||
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/wizard-env');
|
||||
const data: WizardEnvType = await response.json();
|
||||
setWizardEnv(data);
|
||||
} catch (error) {
|
||||
console.log('Fetching wizard env error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, [router.pathname]);
|
||||
|
||||
const fetcher = async (url: string) => await fetch(url).then((res) => res.json());
|
||||
const { data, error } = useSWR('/api/v1/repositories', fetcher);
|
||||
|
||||
if (!data) {
|
||||
mutate('/api/v1/repositories');
|
||||
return <ShimmerRepoList />;
|
||||
}
|
||||
|
||||
if (error || data.status == 500) {
|
||||
toast.error('Error loading repositories.', toastOptions);
|
||||
return <ToastContainer />;
|
||||
}
|
||||
|
||||
const handleSortChange = (option: SortOption) => {
|
||||
setSortOption(option);
|
||||
localStorage.setItem('repoSort', option);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setSearchQuery(query);
|
||||
localStorage.setItem('repoSearch', query);
|
||||
};
|
||||
|
||||
const getSortedRepoList = () => {
|
||||
let repoList = [...data.repoList];
|
||||
|
||||
// Filter
|
||||
if (searchQuery) {
|
||||
repoList = repoList.filter((repo) =>
|
||||
`${repo.alias} ${repo.comment} ${repo.repositoryName}`
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
switch (sortOption) {
|
||||
case 'alias-asc':
|
||||
return repoList.sort((a, b) => a.alias.localeCompare(b.alias));
|
||||
case 'alias-desc':
|
||||
return repoList.sort((a, b) => b.alias.localeCompare(a.alias));
|
||||
case 'status-true':
|
||||
return repoList.sort((a, b) => Number(b.status) - Number(a.status));
|
||||
case 'status-false':
|
||||
return repoList.sort((a, b) => Number(a.status) - Number(b.status));
|
||||
case 'storage-used-asc':
|
||||
return repoList.sort((a, b) => {
|
||||
const aRatio = a.storageSize ? a.storageUsed / a.storageSize : 0;
|
||||
const bRatio = b.storageSize ? b.storageUsed / b.storageSize : 0;
|
||||
return aRatio - bRatio;
|
||||
});
|
||||
case 'storage-used-desc':
|
||||
return repoList.sort((a, b) => {
|
||||
const aRatio = a.storageSize ? a.storageUsed / a.storageSize : 0;
|
||||
const bRatio = b.storageSize ? b.storageUsed / b.storageSize : 0;
|
||||
return bRatio - aRatio;
|
||||
});
|
||||
case 'last-save-asc':
|
||||
return repoList.sort((a, b) => {
|
||||
const aDate = a.lastSave ? new Date(a.lastSave).getTime() : 0;
|
||||
const bDate = b.lastSave ? new Date(b.lastSave).getTime() : 0;
|
||||
return aDate - bDate;
|
||||
});
|
||||
case 'last-save-desc':
|
||||
return repoList.sort((a, b) => {
|
||||
const aDate = a.lastSave ? new Date(a.lastSave).getTime() : 0;
|
||||
const bDate = b.lastSave ? new Date(b.lastSave).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
default:
|
||||
return repoList;
|
||||
}
|
||||
};
|
||||
|
||||
const manageRepoAddHandler = () => router.replace('/manage-repo/add');
|
||||
const manageRepoEditHandler = (id: number) => router.replace('/manage-repo/edit/' + id);
|
||||
const closeRepoManageBoxHandler = () => router.replace('/');
|
||||
const displayBlur = () =>
|
||||
displayRepoAdd || displayRepoEdit ? classes.containerBlur : classes.container;
|
||||
|
||||
const renderRepoList = getSortedRepoList().map((repo: Repository) => (
|
||||
<Repo
|
||||
key={repo.id}
|
||||
id={repo.id}
|
||||
alias={repo.alias}
|
||||
status={repo.status}
|
||||
lastSave={repo.lastSave}
|
||||
alert={repo.alert}
|
||||
repositoryName={repo.repositoryName}
|
||||
storageUsed={repo.storageUsed}
|
||||
storageSize={repo.storageSize}
|
||||
sshPublicKey={repo.sshPublicKey}
|
||||
comment={repo.comment}
|
||||
lanCommand={repo.lanCommand}
|
||||
appendOnlyMode={repo.appendOnlyMode}
|
||||
repoManageEditHandler={() => manageRepoEditHandler(repo.id)}
|
||||
wizardEnv={wizardEnv}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={displayBlur()}>
|
||||
<div className={classes.containerAddRepo}>
|
||||
<Link
|
||||
href='/manage-repo/add'
|
||||
className={classes.newRepoButton}
|
||||
onClick={manageRepoAddHandler}
|
||||
>
|
||||
<IconPlus className={classes.plusIcon} size={24} stroke={2} />
|
||||
<span>Add a repository</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={classes.toolbar}>
|
||||
<div className={classes.searchContainer}>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Alias, comment, repository name...'
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className={classes.searchInput}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSearchChange({
|
||||
target: { value: '' },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
className={classes.clearButton}
|
||||
title='Clear search'
|
||||
>
|
||||
<IconX size={16} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classes.sortIcons}>
|
||||
<IconSortAscendingLetters
|
||||
className={sortOption === 'alias-asc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('alias-asc')}
|
||||
title='Alias A-Z'
|
||||
/>
|
||||
<IconSortDescendingLetters
|
||||
className={sortOption === 'alias-desc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('alias-desc')}
|
||||
title='Alias Z-A'
|
||||
/>
|
||||
<IconSortDescending2Filled
|
||||
className={sortOption === 'status-true' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('status-true')}
|
||||
title='Status OK → KO'
|
||||
/>
|
||||
<IconSortDescending2
|
||||
className={sortOption === 'status-false' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('status-false')}
|
||||
title='Status KO → OK'
|
||||
/>
|
||||
<IconCalendarDown
|
||||
className={sortOption === 'last-save-desc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('last-save-desc')}
|
||||
title='Last save (recent → old)'
|
||||
/>
|
||||
<IconCalendarUp
|
||||
className={sortOption === 'last-save-asc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('last-save-asc')}
|
||||
title='Last save (old → recent)'
|
||||
/>
|
||||
<IconSortAscendingSmallBig
|
||||
className={sortOption === 'storage-used-asc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('storage-used-asc')}
|
||||
title='Storage usage % low → high'
|
||||
/>
|
||||
<IconSortDescendingSmallBig
|
||||
className={sortOption === 'storage-used-desc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('storage-used-desc')}
|
||||
title='Storage usage % high → low'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.containerRepoList}>
|
||||
<div className={classes.RepoList}>{renderRepoList}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayRepoAdd && (
|
||||
<RepoManage mode='add' repoList={data.repoList} closeHandler={closeRepoManageBoxHandler} />
|
||||
)}
|
||||
{displayRepoEdit && (
|
||||
<RepoManage mode='edit' repoList={data.repoList} closeHandler={closeRepoManageBoxHandler} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
.modale {
|
||||
position: fixed;
|
||||
top: 10%;
|
||||
width: 1000px;
|
||||
width: 800px;
|
||||
height: auto;
|
||||
max-width: 75%;
|
||||
max-height: 85%;
|
||||
|
|
@ -24,6 +24,11 @@
|
|||
animation: append-animate 0.3s linear;
|
||||
}
|
||||
|
||||
.modale h2 {
|
||||
margin-top: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
@keyframes append-animate {
|
||||
from {
|
||||
transform: scale(0);
|
||||
|
|
@ -47,89 +52,98 @@
|
|||
|
||||
.repoManageForm {
|
||||
margin: auto;
|
||||
width: 80%;
|
||||
padding: 15px 30px 30px 30px;
|
||||
border-radius: 5px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
font-family: Inter, sans-serif;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.formWrapper {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: #494b7a;
|
||||
margin: 0 auto;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.repoManageForm label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #494b7a;
|
||||
margin-top: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.repoManageForm input,
|
||||
.repoManageForm textarea,
|
||||
.repoManageForm select {
|
||||
border: 1px solid #6d4aff21;
|
||||
font-size: 16px;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
margin-bottom: 0px;
|
||||
outline: 0;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
/* color: #1b1340; */
|
||||
color: #494b7a;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
font-family: Inter;
|
||||
}
|
||||
|
||||
.repoManageForm textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.repoManageForm textarea:focus,
|
||||
.repoManageForm input:focus,
|
||||
.repoManageForm textarea:focus,
|
||||
.repoManageForm select:focus {
|
||||
outline: 1px solid #6d4aff;
|
||||
box-shadow: 0 0 10px 3px rgba(110, 74, 255, 0.605);
|
||||
border-color: #6d4aff;
|
||||
background-color: #ffffff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(109, 74, 255, 0.3);
|
||||
}
|
||||
|
||||
.repoManageForm .invalid {
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
background-color: #fef2f2;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.repoManageForm .invalid:focus {
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
box-shadow: 0 0 10px 3px rgba(255, 74, 74, 0.605);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.repoManageForm button {
|
||||
display: block;
|
||||
margin: 15px auto;
|
||||
margin: 2rem auto 0 auto;
|
||||
background-color: #6d4aff;
|
||||
color: #ffffff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.repoManageForm button:hover {
|
||||
display: block;
|
||||
margin: 15px auto;
|
||||
background-color: #5c3dff;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: red;
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.optionCommandWrapper {
|
||||
display: flex;
|
||||
margin-top: 20px;
|
||||
color: #494b7a;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.optionCommandWrapper label {
|
||||
|
|
@ -137,15 +151,33 @@
|
|||
}
|
||||
|
||||
.optionCommandWrapper input[type='checkbox'] {
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #6d4aff;
|
||||
cursor: pointer;
|
||||
accent-color: #6d4aff;
|
||||
}
|
||||
|
||||
.optionCommandWrapper input[type='checkbox']:focus {
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
accent-color: #6d4aff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(109, 74, 255, 0.4);
|
||||
}
|
||||
|
||||
.selectAlert {
|
||||
max-width: 160px;
|
||||
}
|
||||
.selectAlertWrapper label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.selectAlertWrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* DELETE DIALOG */
|
||||
|
|
@ -254,6 +286,7 @@
|
|||
}
|
||||
|
||||
.littleDeleteButton {
|
||||
margin-top: 10px;
|
||||
border: none;
|
||||
font-weight: 300;
|
||||
color: red;
|
||||
|
|
@ -261,8 +294,3 @@
|
|||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectAlert {
|
||||
margin: auto auto 35px auto;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,46 @@
|
|||
//Lib
|
||||
import classes from './RepoManage.module.css';
|
||||
import { IconAlertCircle, IconX } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
import Select from 'react-select';
|
||||
import { IconAlertCircle, IconExternalLink, IconX } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import { alertOptions } from '../../domain/constants';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import Select from 'react-select';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { alertOptions, Optional, Repository } from '~/types';
|
||||
import classes from './RepoManage.module.css';
|
||||
|
||||
export default function RepoManage(props) {
|
||||
////Var
|
||||
let targetRepo;
|
||||
type RepoManageProps = {
|
||||
mode: 'add' | 'edit';
|
||||
repoList: Optional<Array<Repository>>;
|
||||
closeHandler: () => void;
|
||||
};
|
||||
|
||||
type DataForm = {
|
||||
alias: string;
|
||||
storageSize: string;
|
||||
sshkey: string;
|
||||
comment: string;
|
||||
alert: { value: Optional<number>; label: string };
|
||||
lanCommand: boolean;
|
||||
appendOnlyMode: boolean;
|
||||
};
|
||||
|
||||
export default function RepoManage(props: RepoManageProps) {
|
||||
const router = useRouter();
|
||||
const targetRepo =
|
||||
props.mode === 'edit' && router.query.slug
|
||||
? props.repoList?.find((repo) => repo.id.toString() === router.query.slug)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
} = useForm<DataForm>({ mode: 'onChange' });
|
||||
|
||||
const toastOptions = {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -33,108 +50,113 @@ export default function RepoManage(props) {
|
|||
progress: undefined,
|
||||
};
|
||||
|
||||
////State
|
||||
const [deleteDialog, setDeleteDialog] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
////Functions
|
||||
//router.query.slug is undefined for few milliseconds on first render for a direct URL access (https://github.com/vercel/next.js/discussions/11484).
|
||||
//If I call repoManage with edit mode (props), i'm firstly waiting that router.query.slug being available before rendering.
|
||||
if (!router.query.slug && props.mode == 'edit') {
|
||||
return <SpinnerDotted size={30} thickness={100} speed={180} color='rgba(109, 74, 255, 1)' />;
|
||||
} else if (props.mode == 'edit') {
|
||||
for (let element in props.repoList) {
|
||||
if (props.repoList[element].id == router.query.slug) {
|
||||
targetRepo = props.repoList[element];
|
||||
}
|
||||
}
|
||||
//If the ID does not exist > 404
|
||||
if (!targetRepo) {
|
||||
if (props.mode === 'edit') {
|
||||
if (!router.query.slug) {
|
||||
start();
|
||||
return;
|
||||
} else if (!targetRepo) {
|
||||
stop();
|
||||
router.push('/404');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
//Delete a repo
|
||||
const deleteHandler = async () => {
|
||||
const deleteHandler = async (repositoryName?: string) => {
|
||||
start();
|
||||
if (!repositoryName) {
|
||||
stop();
|
||||
toast.error('Repository name not found', toastOptions);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
//API Call for delete
|
||||
fetch('/api/repo/id/' + router.query.slug + '/delete', {
|
||||
await fetch('/api/v1/repositories/' + repositoryName, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'🗑 The repository #' + router.query.slug + ' has been successfully deleted',
|
||||
'🗑 The repository ' + repositoryName + ' has been successfully deleted',
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
} else {
|
||||
if (response.status == 403)
|
||||
if (response.status == 403) {
|
||||
toast.warning(
|
||||
'🔒 The server is currently protected against repository deletion.',
|
||||
toastOptions
|
||||
);
|
||||
else toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log('Fail to delete');
|
||||
setIsLoading(false);
|
||||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions);
|
||||
router.replace('/');
|
||||
console.log('Fail to delete');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
});
|
||||
};
|
||||
|
||||
//Verify that the SSH key is unique
|
||||
const isSSHKeyUnique = async (sshPublicKey) => {
|
||||
let isUnique = true;
|
||||
const isSSHKeyUnique = async (sshPublicKey: string): Promise<boolean> => {
|
||||
try {
|
||||
// Extract the first two columns of the SSH key in the form
|
||||
const publicKeyPrefix = sshPublicKey.split(' ').slice(0, 2).join(' ');
|
||||
|
||||
// Extract the first two columns of the SSH key in the form
|
||||
const publicKeyPrefix = sshPublicKey.split(' ').slice(0, 2).join(' ');
|
||||
const response = await fetch('/api/v1/repositories', { method: 'GET' });
|
||||
const data: { repoList: Repository[] } = await response.json();
|
||||
|
||||
await fetch('/api/repo', { method: 'GET' })
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
for (let element in data.repoList) {
|
||||
// Extract the first two columns of the SSH key in the repoList
|
||||
const repoPublicKeyPrefix = data.repoList[element].sshPublicKey
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.join(' ');
|
||||
|
||||
if (
|
||||
repoPublicKeyPrefix === publicKeyPrefix && // Compare the first two columns of the SSH key
|
||||
(!targetRepo || data.repoList[element].id != targetRepo.id)
|
||||
) {
|
||||
toast.error(
|
||||
'The SSH key is already used in repository #' +
|
||||
data.repoList[element].id +
|
||||
'. Please use another key or delete the key from the other repository.',
|
||||
toastOptions
|
||||
);
|
||||
isUnique = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
isUnique = false;
|
||||
const conflictingRepo = data.repoList.find((repo: { sshPublicKey: string; id: number }) => {
|
||||
const repoPublicKeyPrefix = repo.sshPublicKey.split(' ').slice(0, 2).join(' ');
|
||||
return (
|
||||
repoPublicKeyPrefix === publicKeyPrefix && (!targetRepo || repo.id !== targetRepo.id)
|
||||
);
|
||||
});
|
||||
return isUnique;
|
||||
|
||||
if (conflictingRepo) {
|
||||
toast.error(
|
||||
`The SSH key is already used in repository ${conflictingRepo.repositoryName}. Please use another key or delete the key from the other repository.`,
|
||||
toastOptions
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
//Form submit Handler for ADD or EDIT a repo
|
||||
const formSubmitHandler = async (dataForm) => {
|
||||
//Loading button on submit to avoid multiple send.
|
||||
const formSubmitHandler = async (dataForm: DataForm) => {
|
||||
setIsLoading(true);
|
||||
start();
|
||||
|
||||
// Clean SSH key by removing leading/trailing whitespace and line breaks
|
||||
const cleanedSSHKey = dataForm.sshkey.trim();
|
||||
|
||||
//Verify that the SSH key is unique
|
||||
if (!(await isSSHKeyUnique(dataForm.sshkey))) {
|
||||
if (!(await isSSHKeyUnique(cleanedSSHKey))) {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -143,14 +165,14 @@ export default function RepoManage(props) {
|
|||
const newRepo = {
|
||||
alias: dataForm.alias,
|
||||
storageSize: parseInt(dataForm.storageSize),
|
||||
sshPublicKey: dataForm.sshkey,
|
||||
sshPublicKey: cleanedSSHKey,
|
||||
comment: dataForm.comment,
|
||||
alert: dataForm.alert.value,
|
||||
lanCommand: dataForm.lanCommand,
|
||||
appendOnlyMode: dataForm.appendOnlyMode,
|
||||
};
|
||||
//POST API to send new repo
|
||||
await fetch('/api/repo/add', {
|
||||
await fetch('/api/v1/repositories', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -163,7 +185,7 @@ export default function RepoManage(props) {
|
|||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(`An error has occurred : ${errorMessage.message}`, toastOptions);
|
||||
toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions);
|
||||
router.replace('/');
|
||||
console.log(`Fail to ${props.mode}`);
|
||||
}
|
||||
|
|
@ -172,19 +194,23 @@ export default function RepoManage(props) {
|
|||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
});
|
||||
//EDIT a repo
|
||||
} else if (props.mode == 'edit') {
|
||||
const dataEdited = {
|
||||
alias: dataForm.alias,
|
||||
storageSize: parseInt(dataForm.storageSize),
|
||||
sshPublicKey: dataForm.sshkey,
|
||||
sshPublicKey: cleanedSSHKey,
|
||||
comment: dataForm.comment,
|
||||
alert: dataForm.alert.value,
|
||||
lanCommand: dataForm.lanCommand,
|
||||
appendOnlyMode: dataForm.appendOnlyMode,
|
||||
};
|
||||
await fetch('/api/repo/id/' + router.query.slug + '/edit', {
|
||||
await fetch('/api/v1/repositories/' + targetRepo?.repositoryName, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -194,13 +220,13 @@ export default function RepoManage(props) {
|
|||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'The repository #' + targetRepo.id + ' has been successfully edited !',
|
||||
'The repository ' + targetRepo?.repositoryName + ' has been successfully edited !',
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(`An error has occurred : ${errorMessage.message}`, toastOptions);
|
||||
toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions);
|
||||
router.replace('/');
|
||||
console.log(`Fail to ${props.mode}`);
|
||||
}
|
||||
|
|
@ -209,6 +235,10 @@ export default function RepoManage(props) {
|
|||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -231,54 +261,54 @@ export default function RepoManage(props) {
|
|||
color: 'rgba(99, 115, 129, 0.38)',
|
||||
}}
|
||||
>
|
||||
#{targetRepo.id}
|
||||
{targetRepo?.repositoryName}
|
||||
</span>{' '}
|
||||
?
|
||||
</h1>
|
||||
</div>
|
||||
<div className={classes.deleteDialogMessage}>
|
||||
<div style={{ marginBottom: '5px' }}>
|
||||
You are about to permanently delete the repository <b>#{targetRepo.id}</b> and all
|
||||
the backups it contains.
|
||||
You are about to permanently delete the repository{' '}
|
||||
<b>{targetRepo?.repositoryName}</b> and all the backups it contains.
|
||||
</div>
|
||||
<div>The data will not be recoverable and it will not be possible to go back.</div>
|
||||
</div>
|
||||
<div className={classes.deleteDialogButtonWrapper}>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={30} thickness={150} speed={100} color='#6d4aff' />
|
||||
) : (
|
||||
<>
|
||||
<button onClick={props.closeHandler} className={classes.cancelButton}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteHandler();
|
||||
setIsLoading(true);
|
||||
}}
|
||||
className={classes.deleteButton}
|
||||
>
|
||||
Yes, delete it !
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<button
|
||||
onClick={props.closeHandler}
|
||||
disabled={isLoading}
|
||||
className={classes.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteHandler(targetRepo?.repositoryName);
|
||||
setIsLoading(true);
|
||||
}}
|
||||
className={classes.deleteButton}
|
||||
>
|
||||
Yes, delete it !
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.formWrapper}>
|
||||
{props.mode == 'edit' && (
|
||||
<h1>
|
||||
<h2>
|
||||
Edit the repository{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(99, 115, 129, 0.38)',
|
||||
color: '#6d4aff',
|
||||
}}
|
||||
>
|
||||
#{targetRepo.id}
|
||||
{targetRepo?.repositoryName}
|
||||
</span>
|
||||
</h1>
|
||||
</h2>
|
||||
)}
|
||||
{props.mode == 'add' && <h1>Add a repository</h1>}
|
||||
{props.mode == 'add' && <h2>Add a repository</h2>}
|
||||
<form className={classes.repoManageForm} onSubmit={handleSubmit(formSubmitHandler)}>
|
||||
{/* ALIAS */}
|
||||
<label htmlFor='alias'>Alias</label>
|
||||
|
|
@ -286,16 +316,16 @@ export default function RepoManage(props) {
|
|||
className='form-control is-invalid'
|
||||
placeholder='Alias for the repository, e.g."Server 1"'
|
||||
type='text'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo.alias : null}
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.alias : undefined}
|
||||
{...register('alias', {
|
||||
required: 'An alias is required.',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: '2 characters min',
|
||||
value: 1,
|
||||
message: '1 character min',
|
||||
},
|
||||
maxLength: {
|
||||
value: 40,
|
||||
message: '40 characters max',
|
||||
value: 100,
|
||||
message: '100 characters max',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
@ -304,15 +334,17 @@ export default function RepoManage(props) {
|
|||
<label htmlFor='sshkey'>SSH public key</label>
|
||||
<textarea
|
||||
placeholder='Public key in OpenSSH format (rsa, ed25519, ed25519-sk)'
|
||||
type='text'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo.sshPublicKey : null}
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.sshPublicKey : undefined}
|
||||
{...register('sshkey', {
|
||||
required: 'SSH public key is required.',
|
||||
pattern: {
|
||||
value:
|
||||
/^(ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3}(\s.*)?$/,
|
||||
message:
|
||||
'Invalid public key. The SSH key needs to be in OpenSSH format (rsa, ed25519, ed25519-sk)',
|
||||
validate: (value) => {
|
||||
const trimmedValue = value.trim();
|
||||
const pattern =
|
||||
/^(ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3}(\s.*)?$/;
|
||||
return (
|
||||
pattern.test(trimmedValue) ||
|
||||
'Invalid public key. The key needs to be in OpenSSH format (rsa, ed25519, ed25519-sk)'
|
||||
);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
@ -323,8 +355,9 @@ export default function RepoManage(props) {
|
|||
<label htmlFor='storageSize'>Storage Size (GB)</label>
|
||||
<input
|
||||
type='number'
|
||||
placeholder='1000'
|
||||
min='1'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo.storageSize : null}
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.storageSize : undefined}
|
||||
{...register('storageSize', {
|
||||
required: 'A storage size is required.',
|
||||
})}
|
||||
|
|
@ -335,14 +368,12 @@ export default function RepoManage(props) {
|
|||
{/* COMMENT */}
|
||||
<label htmlFor='comment'>Comment</label>
|
||||
<textarea
|
||||
type='text'
|
||||
placeholder='Little comment for your repository...'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo.comment : null}
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.comment : undefined}
|
||||
{...register('comment', {
|
||||
required: false,
|
||||
maxLength: {
|
||||
value: 200,
|
||||
message: '200 characters maximum.',
|
||||
value: 500,
|
||||
message: '500 characters maximum.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
@ -353,16 +384,11 @@ export default function RepoManage(props) {
|
|||
<div className={classes.optionCommandWrapper}>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='lanCommand'
|
||||
defaultChecked={props.mode == 'edit' ? targetRepo.lanCommand : false}
|
||||
defaultChecked={props.mode == 'edit' ? targetRepo?.lanCommand : false}
|
||||
{...register('lanCommand')}
|
||||
/>
|
||||
<label htmlFor='lanCommand'>Generates commands for use over LAN.</label>
|
||||
<label htmlFor='lanCommand'>Generates commands for use over LAN</label>
|
||||
<Link
|
||||
style={{
|
||||
alignSelf: 'baseline',
|
||||
marginLeft: '5px',
|
||||
}}
|
||||
href='https://borgwarehouse.com/docs/user-manual/repositories/#generates-commands-for-use-over-lan'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
|
|
@ -374,16 +400,11 @@ export default function RepoManage(props) {
|
|||
<div className={classes.optionCommandWrapper}>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='appendOnlyMode'
|
||||
defaultChecked={props.mode == 'edit' ? targetRepo.appendOnlyMode : false}
|
||||
defaultChecked={props.mode == 'edit' ? targetRepo?.appendOnlyMode : false}
|
||||
{...register('appendOnlyMode')}
|
||||
/>
|
||||
<label htmlFor='appendOnlyMode'>Enable append-only mode.</label>
|
||||
<label htmlFor='appendOnlyMode'>Enable append-only mode</label>
|
||||
<Link
|
||||
style={{
|
||||
alignSelf: 'baseline',
|
||||
marginLeft: '5px',
|
||||
}}
|
||||
href='https://borgwarehouse.com/docs/user-manual/repositories/#append-only-mode'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
|
|
@ -392,57 +413,75 @@ export default function RepoManage(props) {
|
|||
</Link>
|
||||
</div>
|
||||
{/* ALERT */}
|
||||
<label style={{ margin: '25px auto 10px auto' }} htmlFor='alert'>
|
||||
Alert if there is no backup since :
|
||||
</label>
|
||||
<div className={classes.selectAlert}>
|
||||
<Controller
|
||||
name='alert'
|
||||
defaultValue={
|
||||
props.mode == 'edit'
|
||||
? alertOptions.find((x) => x.value === targetRepo.alert) || {
|
||||
value: targetRepo.alert,
|
||||
label: `${targetRepo.alert} seconds (custom)`,
|
||||
}
|
||||
: alertOptions[4]
|
||||
}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={alertOptions}
|
||||
isSearchable={false}
|
||||
maxMenuHeight={150}
|
||||
menuPlacement='top'
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: '5px',
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary25: '#c3b6fa',
|
||||
primary: '#6d4aff',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<SpinnerDotted size={30} thickness={150} speed={100} color='#6d4aff' />
|
||||
<div className={classes.selectAlertWrapper}>
|
||||
<label htmlFor='alert'>Alert if there is no backup since :</label>
|
||||
<div className={classes.selectAlert}>
|
||||
<Controller
|
||||
name='alert'
|
||||
defaultValue={
|
||||
props.mode == 'edit'
|
||||
? alertOptions.find((x) => x.value === targetRepo?.alert) || {
|
||||
value: targetRepo?.alert,
|
||||
label: `Custom value (${targetRepo?.alert} seconds)`,
|
||||
}
|
||||
: alertOptions[4]
|
||||
}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={alertOptions}
|
||||
isSearchable={false}
|
||||
maxMenuHeight={300}
|
||||
menuPlacement='top'
|
||||
styles={{
|
||||
control: (base) => ({
|
||||
...base,
|
||||
minHeight: '35px',
|
||||
height: '35px',
|
||||
}),
|
||||
valueContainer: (base) => ({
|
||||
...base,
|
||||
height: '35px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
padding: '0 8px',
|
||||
}),
|
||||
input: (base) => ({
|
||||
...base,
|
||||
margin: 0,
|
||||
}),
|
||||
indicatorsContainer: (base) => ({
|
||||
...base,
|
||||
height: '35px',
|
||||
}),
|
||||
}}
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 5,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary25: '#c3b6fa',
|
||||
primary: '#6d4aff',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button type='submit' className='defaultButton' disabled={!isValid || isSubmitting}>
|
||||
{props.mode == 'edit' && 'Edit'}
|
||||
{props.mode == 'add' && 'Add'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
className='defaultButton'
|
||||
disabled={!isValid || isSubmitting || isLoading}
|
||||
>
|
||||
{props.mode == 'edit' && 'Save'}
|
||||
{props.mode == 'add' && 'Add repository'}
|
||||
</button>
|
||||
</form>
|
||||
{props.mode == 'edit' ? (
|
||||
<button className={classes.littleDeleteButton} onClick={() => setDeleteDialog(true)}>
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from './SetupWizard.module.css';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Select from 'react-select';
|
||||
|
||||
//Components
|
||||
import WizardStepBar from '../../Components/WizardSteps/WizardStepBar/WizardStepBar';
|
||||
import WizardStep1 from '../../Components/WizardSteps/WizardStep1/WizardStep1';
|
||||
import WizardStep2 from '../../Components/WizardSteps/WizardStep2/WizardStep2';
|
||||
import WizardStep3 from '../../Components/WizardSteps/WizardStep3/WizardStep3';
|
||||
import WizardStep4 from '../../Components/WizardSteps/WizardStep4/WizardStep4';
|
||||
|
||||
function SetupWizard(props) {
|
||||
////Var
|
||||
const router = useRouter();
|
||||
|
||||
////States
|
||||
const [list, setList] = useState([]);
|
||||
const [listIsLoading, setListIsLoading] = useState(true);
|
||||
const [step, setStep] = useState();
|
||||
const [wizardEnv, setWizardEnv] = useState({});
|
||||
const [selectedOption, setSelectedOption] = useState({
|
||||
id: '#id',
|
||||
repository: 'repo',
|
||||
});
|
||||
|
||||
////LifeCycle
|
||||
//ComponentDidMount
|
||||
useEffect(() => {
|
||||
//retrieve the repository list
|
||||
const repoList = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/repo', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setList((await response.json()).repoList);
|
||||
setListIsLoading(false);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
repoList();
|
||||
//Fetch wizardEnv to hydrate Wizard' steps
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getWizardEnv', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setWizardEnv((await response.json()).wizardEnv);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
//Component did update
|
||||
useEffect(() => {
|
||||
//Go to the step in the URL param when URL change
|
||||
setStep(props.step);
|
||||
}, [props.step]);
|
||||
|
||||
////Functions
|
||||
|
||||
//Options for react-select
|
||||
const options = list.map((repo) => ({
|
||||
label: `${repo.alias} - #${repo.id}`,
|
||||
value: `${repo.alias} - #${repo.id}`,
|
||||
id: repo.id,
|
||||
repositoryName: repo.repositoryName,
|
||||
lanCommand: repo.lanCommand,
|
||||
}));
|
||||
|
||||
//Step button (free selection of user)
|
||||
const changeStepHandler = (x) => router.push('/setup-wizard/' + x);
|
||||
|
||||
//Next Step button
|
||||
const nextStepHandler = () => {
|
||||
if (step < 4) {
|
||||
router.push('/setup-wizard/' + `${Number(step) + 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
//Previous Step button
|
||||
const previousStepHandler = () => {
|
||||
if (step > 1) {
|
||||
router.push('/setup-wizard/' + `${Number(step) - 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
//Change Step with State
|
||||
const wizardStep = (step) => {
|
||||
if (step == 1) {
|
||||
return <WizardStep1 />;
|
||||
} else if (step == 2) {
|
||||
return <WizardStep2 selectedOption={selectedOption} wizardEnv={wizardEnv} />;
|
||||
} else if (step == 3) {
|
||||
return <WizardStep3 selectedOption={selectedOption} wizardEnv={wizardEnv} />;
|
||||
} else {
|
||||
return <WizardStep4 selectedOption={selectedOption} wizardEnv={wizardEnv} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<WizardStepBar
|
||||
setStep={(x) => changeStepHandler(x)}
|
||||
step={step}
|
||||
nextStepHandler={() => nextStepHandler()}
|
||||
previousStepHandler={() => previousStepHandler()}
|
||||
/>
|
||||
<div className={classes.selectRepo}>
|
||||
<Select
|
||||
onChange={setSelectedOption}
|
||||
isLoading={listIsLoading}
|
||||
isDisabled={listIsLoading}
|
||||
options={options}
|
||||
isSearchable
|
||||
placeholder='Select your repository...'
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: '5px',
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary25: '#c3b6fa',
|
||||
primary: '#6d4aff',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{wizardStep(step)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupWizard;
|
||||
170
Containers/SetupWizard/SetupWizard.tsx
Normal file
170
Containers/SetupWizard/SetupWizard.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Select, { SingleValue } from 'react-select';
|
||||
import classes from './SetupWizard.module.css';
|
||||
import { Optional, SelectedRepoWizard, Repository, WizardEnvType } from '~/types';
|
||||
|
||||
//Components
|
||||
import WizardStep1 from '../../Components/WizardSteps/WizardStep1/WizardStep1';
|
||||
import WizardStep2 from '../../Components/WizardSteps/WizardStep2/WizardStep2';
|
||||
import WizardStep3 from '../../Components/WizardSteps/WizardStep3/WizardStep3';
|
||||
import WizardStep4 from '../../Components/WizardSteps/WizardStep4/WizardStep4';
|
||||
import WizardStepBar from '../../Components/WizardSteps/WizardStepBar/WizardStepBar';
|
||||
|
||||
type SetupWizardProps = {
|
||||
step?: number;
|
||||
};
|
||||
|
||||
function SetupWizard(props: SetupWizardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [repoList, setRepoList] = useState<Optional<Array<Repository>>>();
|
||||
const [repoListIsLoading, setRepoListIsLoading] = useState<boolean>(true);
|
||||
const [step, setStep] = useState<number>(1);
|
||||
const [wizardEnv, setWizardEnv] = useState<Optional<WizardEnvType>>();
|
||||
const [selectedItem, setSelectedItem] = useState<Optional<SelectedRepoWizard>>();
|
||||
|
||||
////LifeCycle
|
||||
//ComponentDidMount
|
||||
useEffect(() => {
|
||||
//retrieve the repository list
|
||||
const fetchRepoList = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/repositories', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const repos = data.repoList;
|
||||
setRepoList(repos);
|
||||
setRepoListIsLoading(false);
|
||||
|
||||
// Auto-select first repository if available
|
||||
if (repos && repos.length > 0) {
|
||||
setSelectedItem({
|
||||
label: `${repos[0].alias} - ${repos[0].repositoryName}`,
|
||||
value: `${repos[0].alias} - ${repos[0].repositoryName}`,
|
||||
id: repos[0].id.toString(),
|
||||
repositoryName: repos[0].repositoryName,
|
||||
lanCommand: repos[0].lanCommand ? repos[0].lanCommand : false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchRepoList();
|
||||
//Fetch wizardEnv to hydrate Wizard' steps
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/wizard-env', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const data: WizardEnvType = await response.json();
|
||||
setWizardEnv(data);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
//Component did update
|
||||
useEffect(() => {
|
||||
//Go to the step in the URL param when URL change
|
||||
if (props.step) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStep(props.step);
|
||||
}
|
||||
}, [props.step]);
|
||||
|
||||
//Options for react-select
|
||||
const options: Optional<Array<SelectedRepoWizard>> = useMemo(
|
||||
() =>
|
||||
repoList?.map((repo) => ({
|
||||
label: `${repo.alias} - ${repo.repositoryName}`,
|
||||
value: `${repo.alias} - ${repo.repositoryName}`,
|
||||
id: repo.id.toString(),
|
||||
repositoryName: repo.repositoryName,
|
||||
lanCommand: repo.lanCommand ? repo.lanCommand : false,
|
||||
})),
|
||||
[repoList]
|
||||
);
|
||||
|
||||
//Step button (free selection of user)
|
||||
const changeStepHandler = (x: number) => router.push('/setup-wizard/' + x.toString());
|
||||
|
||||
//Next Step button
|
||||
const nextStepHandler = () => {
|
||||
if (step && step < 4) {
|
||||
router.push('/setup-wizard/' + `${step + 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
//Previous Step button
|
||||
const previousStepHandler = () => {
|
||||
if (step && step > 1) {
|
||||
router.push('/setup-wizard/' + `${step - 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeSelect = (option: SingleValue<SelectedRepoWizard>) => {
|
||||
if (option) {
|
||||
setSelectedItem(option);
|
||||
} else {
|
||||
setSelectedItem(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
//Change Step with State
|
||||
const wizardStep = (step?: number) => {
|
||||
if (!step || step === 1) {
|
||||
return <WizardStep1 />;
|
||||
} else if (step === 2) {
|
||||
return <WizardStep2 selectedRepo={selectedItem} wizardEnv={wizardEnv} />;
|
||||
} else if (step === 3) {
|
||||
return <WizardStep3 selectedRepo={selectedItem} wizardEnv={wizardEnv} />;
|
||||
} else {
|
||||
return <WizardStep4 selectedRepo={selectedItem} wizardEnv={wizardEnv} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<WizardStepBar
|
||||
setStep={(x) => changeStepHandler(x)}
|
||||
step={step}
|
||||
nextStepHandler={() => nextStepHandler()}
|
||||
previousStepHandler={() => previousStepHandler()}
|
||||
/>
|
||||
<div className={classes.selectRepo}>
|
||||
<Select
|
||||
onChange={(item) => onChangeSelect(item)}
|
||||
isLoading={repoListIsLoading}
|
||||
isDisabled={repoListIsLoading}
|
||||
options={options}
|
||||
isSearchable
|
||||
value={selectedItem}
|
||||
placeholder='Select your repository...'
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 5,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary25: '#c3b6fa',
|
||||
primary: '#6d4aff',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{wizardStep(step)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupWizard;
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Switch from '../../../Components/UI/Switch/Switch';
|
||||
import AppriseURLs from './AppriseURLs/AppriseURLs';
|
||||
import AppriseMode from './AppriseMode/AppriseMode';
|
||||
|
||||
export default function AppriseAlertSettings() {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
//Callback > re-enabled button after notification.
|
||||
onClose: () => setDisabled(false),
|
||||
};
|
||||
|
||||
////State
|
||||
const [checkIsLoading, setCheckIsLoading] = useState(true);
|
||||
const [error, setError] = useState();
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [checked, setChecked] = useState();
|
||||
const [testIsLoading, setTestIsLoading] = useState(false);
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to get the status of Apprise Alert
|
||||
const getAppriseAlert = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getAppriseAlert', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setChecked((await response.json()).appriseAlert);
|
||||
setCheckIsLoading(false);
|
||||
} catch (error) {
|
||||
setError('Fetching apprise alert setting failed. Contact your administrator.');
|
||||
console.log('Fetching apprise alert setting failed.');
|
||||
setCheckIsLoading(false);
|
||||
}
|
||||
};
|
||||
getAppriseAlert();
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Switch to enable/disable Apprise notifications
|
||||
const onChangeSwitchHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Disabled button
|
||||
setDisabled(true);
|
||||
await fetch('/api/account/updateAppriseAlert', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
if (response.ok) {
|
||||
if (data.appriseAlert) {
|
||||
setChecked(!checked);
|
||||
toast.success('Apprise notifications enabled.', toastOptions);
|
||||
} else {
|
||||
setChecked(!checked);
|
||||
toast.success('Apprise notifications disabled.', toastOptions);
|
||||
}
|
||||
} else {
|
||||
setError('Update apprise alert setting failed.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
setDisabled(false);
|
||||
}, 4000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setError('Update Apprise failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
setDisabled(false);
|
||||
}, 4000);
|
||||
});
|
||||
};
|
||||
|
||||
//Send Apprise test notification to services
|
||||
const onSendTestAppriseHandler = async () => {
|
||||
//Loading
|
||||
setTestIsLoading(true);
|
||||
//Remove old error
|
||||
setError();
|
||||
try {
|
||||
const response = await fetch('/api/account/sendTestApprise', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sendTestApprise: true }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
setTestIsLoading(false);
|
||||
setError(result.message);
|
||||
} else {
|
||||
setTestIsLoading(false);
|
||||
setInfo(true);
|
||||
setTimeout(() => {
|
||||
setInfo(false);
|
||||
}, 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestIsLoading(false);
|
||||
console.log(error);
|
||||
setError('Send notification failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* APPRISE ALERT */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Apprise alert</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/user-manual/account/#apprise'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
{/* NOTIFY SWITCH */}
|
||||
{checkIsLoading ? (
|
||||
<SpinnerCircularFixed
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
switchName='Notify my Apprise services'
|
||||
switchDescription='You will receive an alert on all your services every 24H if you have a down status.'
|
||||
onChange={(e) => onChangeSwitchHandler({ appriseAlert: e })}
|
||||
/>
|
||||
)}
|
||||
{/* APPRISE SERVICES URLS */}
|
||||
<AppriseURLs />
|
||||
{/* APPRISE MODE SELECTION */}
|
||||
<AppriseMode />
|
||||
{/* APPRISE TEST BUTTON */}
|
||||
{testIsLoading ? (
|
||||
<SpinnerCircularFixed
|
||||
style={{ marginTop: '20px' }}
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
style={{ marginTop: '20px' }}
|
||||
className='defaultButton'
|
||||
onClick={() => onSendTestAppriseHandler()}
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
)}
|
||||
{info && (
|
||||
<span style={{ marginLeft: '10px', color: '#119300' }}>
|
||||
Notification successfully sent.
|
||||
</span>
|
||||
)}
|
||||
{error && <Error message={error} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Switch from '~/Components/UI/Switch/Switch';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { Optional } from '~/types';
|
||||
import AppriseMode from './AppriseMode/AppriseMode';
|
||||
import AppriseURLs from './AppriseURLs/AppriseURLs';
|
||||
|
||||
type AppriseAlertDataForm = {
|
||||
appriseAlert: boolean;
|
||||
};
|
||||
|
||||
export default function AppriseAlertSettings() {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
////State
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
const [isSwitchDisabled, setIsSwitchDisabled] = useState(true);
|
||||
const [isAlertEnabled, setIsAlertEnabled] = useState<Optional<boolean>>(undefined);
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to get the status of Apprise Alert
|
||||
const getAppriseAlert = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/alert', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data: Optional<AppriseAlertDataForm> = await response.json();
|
||||
setIsAlertEnabled(data?.appriseAlert ?? false);
|
||||
setIsSwitchDisabled(false);
|
||||
} catch (error) {
|
||||
setIsSwitchDisabled(true);
|
||||
setIsAlertEnabled(false);
|
||||
toast.error('Fetching Apprise alert setting failed', toastOptions);
|
||||
}
|
||||
};
|
||||
getAppriseAlert();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Switch to enable/disable Apprise notifications
|
||||
const onChangeSwitchHandler = async (data: AppriseAlertDataForm) => {
|
||||
start();
|
||||
setIsSwitchDisabled(true);
|
||||
await fetch('/api/v1/notif/apprise/alert', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok && typeof data.appriseAlert === 'boolean') {
|
||||
setIsAlertEnabled(data.appriseAlert);
|
||||
toast.success(
|
||||
data.appriseAlert ? 'Apprise notifications enabled' : 'Apprise notifications disabled',
|
||||
toastOptions
|
||||
);
|
||||
} else {
|
||||
toast.error('Update Apprise failed', toastOptions);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Update Apprise failed', toastOptions);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsSwitchDisabled(false);
|
||||
});
|
||||
};
|
||||
|
||||
//Send Apprise test notification to services
|
||||
const onSendTestAppriseHandler = async () => {
|
||||
start();
|
||||
setIsSendingTestNotification(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/test', {
|
||||
method: 'POST',
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
} else {
|
||||
setInfo(true);
|
||||
setTimeout(() => {
|
||||
setInfo(false);
|
||||
}, 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Sending test notification failed', toastOptions);
|
||||
} finally {
|
||||
stop();
|
||||
setIsSendingTestNotification(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* APPRISE ALERT */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Apprise alert</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/user-manual/account/#apprise'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
<Switch
|
||||
loading={isAlertEnabled === undefined}
|
||||
checked={isAlertEnabled}
|
||||
disabled={isSwitchDisabled}
|
||||
switchName='Notify my Apprise services'
|
||||
switchDescription='You will receive an alert on all your services every 24H if you have a down status.'
|
||||
onChange={(e) => onChangeSwitchHandler({ appriseAlert: e })}
|
||||
/>
|
||||
{isAlertEnabled && (
|
||||
<>
|
||||
<AppriseURLs />
|
||||
<AppriseMode />
|
||||
<button
|
||||
disabled={isSendingTestNotification}
|
||||
style={{ marginTop: '20px' }}
|
||||
className='defaultButton'
|
||||
onClick={() => onSendTestAppriseHandler()}
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
{info && (
|
||||
<span style={{ marginLeft: '10px', color: '#119300' }}>
|
||||
Notification successfully sent.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,44 +1,51 @@
|
|||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { AppriseModeDTO, AppriseModeEnum, Optional } from '~/types';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '../../../../Components/UI/Error/Error';
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
|
||||
type AppriseModeDataForm = {
|
||||
appriseMode: string;
|
||||
appriseStatelessURL: string;
|
||||
};
|
||||
|
||||
export default function AppriseMode() {
|
||||
//Var
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ mode: 'onBlur' });
|
||||
} = useForm<AppriseModeDataForm>({ mode: 'onChange' });
|
||||
|
||||
////State
|
||||
const [formIsLoading, setFormIsLoading] = useState(false);
|
||||
const [modeFormIsSaved, setModeFormIsSaved] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [displayStatelessURL, setDisplayStatelessURL] = useState(false);
|
||||
const [appriseMode, setAppriseMode] = useState('stateless');
|
||||
const [appriseStatelessURL, setAppriseStatelessURL] = useState();
|
||||
const { error, setIsLoading, handleSuccess, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const [displayStatelessURL, setDisplayStatelessURL] = useState<boolean>(false);
|
||||
const [appriseMode, setAppriseMode] = useState<Optional<AppriseModeEnum>>(
|
||||
AppriseModeEnum.STATELESS
|
||||
);
|
||||
const [appriseStatelessURL, setAppriseStatelessURL] = useState<Optional<string>>();
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to get Apprise Mode enabled
|
||||
const getAppriseMode = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getAppriseMode', {
|
||||
const response = await fetch('/api/v1/notif/apprise/mode', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const { appriseStatelessURL, appriseMode } = await response.json();
|
||||
|
||||
const data: AppriseModeDTO = await response.json();
|
||||
const { appriseStatelessURL, appriseMode } = data;
|
||||
setAppriseMode(appriseMode);
|
||||
if (appriseMode == 'stateless') {
|
||||
|
||||
if (appriseMode == AppriseModeEnum.STATELESS) {
|
||||
setAppriseStatelessURL(appriseStatelessURL);
|
||||
setDisplayStatelessURL(true);
|
||||
}
|
||||
|
|
@ -50,15 +57,13 @@ export default function AppriseMode() {
|
|||
}, []);
|
||||
|
||||
////Functions
|
||||
//Form submit handler to modify Apprise Mode
|
||||
const modeFormSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setFormIsLoading(true);
|
||||
//POST API to update Apprise Mode
|
||||
const modeFormSubmitHandler = async (data: AppriseModeDataForm) => {
|
||||
clearError();
|
||||
setIsLoading(true);
|
||||
start();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/updateAppriseMode', {
|
||||
const response = await fetch('/api/v1/notif/apprise/mode', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -68,20 +73,15 @@ export default function AppriseMode() {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setFormIsLoading(false);
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
handleError(result.message);
|
||||
} else {
|
||||
setFormIsLoading(false);
|
||||
setModeFormIsSaved(true);
|
||||
setTimeout(() => setModeFormIsSaved(false), 3000);
|
||||
handleSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
setFormIsLoading(false);
|
||||
setError('Change mode failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
handleError('The Apprise mode change has failed');
|
||||
} finally {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -90,23 +90,9 @@ export default function AppriseMode() {
|
|||
{/* APPRISE MODE SELECTION */}
|
||||
<div className={classes.headerFormAppriseUrls}>
|
||||
<div style={{ margin: '0px 10px 0px 0px' }}>Apprise mode</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{formIsLoading && (
|
||||
<SpinnerCircularFixed
|
||||
size={18}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
)}
|
||||
{modeFormIsSaved && (
|
||||
<div className={classes.formIsSavedMessage}>✅ Apprise mode has been saved.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <Error message={error} />}
|
||||
<form className={classes.bwForm} onBlur={handleSubmit(modeFormSubmitHandler)}>
|
||||
<form className={classes.bwForm} onChange={handleSubmit(modeFormSubmitHandler)}>
|
||||
<div className='radio-group'>
|
||||
<label style={{ marginRight: '50px' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
|
|
@ -116,7 +102,7 @@ export default function AppriseMode() {
|
|||
value='package'
|
||||
onClick={() => {
|
||||
setDisplayStatelessURL(false);
|
||||
setAppriseMode('package');
|
||||
setAppriseMode(AppriseModeEnum.PACKAGE);
|
||||
}}
|
||||
checked={appriseMode == 'package' ? true : false}
|
||||
/>
|
||||
|
|
@ -131,7 +117,7 @@ export default function AppriseMode() {
|
|||
type='radio'
|
||||
onClick={() => {
|
||||
setDisplayStatelessURL(true);
|
||||
setAppriseMode('stateless');
|
||||
setAppriseMode(AppriseModeEnum.STATELESS);
|
||||
}}
|
||||
checked={appriseMode == 'stateless' ? true : false}
|
||||
/>
|
||||
|
|
@ -1,65 +1,67 @@
|
|||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { AppriseServicesDTO, Optional } from '~/types';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '../../../../Components/UI/Error/Error';
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
|
||||
type AppriseURLsDataForm = {
|
||||
appriseURLs: string;
|
||||
};
|
||||
|
||||
export default function AppriseURLs() {
|
||||
//Var
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ mode: 'onBlur' });
|
||||
} = useForm<AppriseURLsDataForm>({ mode: 'onBlur' });
|
||||
|
||||
////State
|
||||
const [formIsLoading, setFormIsLoading] = useState(false);
|
||||
const [urlsFormIsSaved, setUrlsFormIsSaved] = useState(false);
|
||||
const [appriseServicesList, setAppriseServicesList] = useState();
|
||||
const [error, setError] = useState();
|
||||
const { isSaved, error, handleSuccess, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const [appriseServicesList, setAppriseServicesList] = useState<Optional<string>>();
|
||||
const [fetchError, setFetchError] = useState<Optional<boolean>>();
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to build the list of Apprise Services enabled
|
||||
const getAppriseServices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getAppriseServices', {
|
||||
const response = await fetch('/api/v1/notif/apprise/services', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
let servicesArray = (await response.json()).appriseServices;
|
||||
const AppriseServicesListToText = () => {
|
||||
let list = '';
|
||||
for (let service of servicesArray) {
|
||||
list += service + '\n';
|
||||
}
|
||||
return list;
|
||||
};
|
||||
setAppriseServicesList(AppriseServicesListToText());
|
||||
|
||||
const data: AppriseServicesDTO = await response.json();
|
||||
const servicesText = data.appriseServices?.join('\n');
|
||||
setAppriseServicesList(servicesText);
|
||||
setFetchError(false);
|
||||
} catch (error) {
|
||||
console.log('Fetching Apprise services list failed.');
|
||||
setFetchError(true);
|
||||
handleError('Fetching Apprise services list failed.');
|
||||
}
|
||||
};
|
||||
getAppriseServices();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Form submit handler to modify Apprise services
|
||||
const urlsFormSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setFormIsLoading(true);
|
||||
//POST API to update Apprise Services
|
||||
const urlsFormSubmitHandler = async (data: AppriseURLsDataForm) => {
|
||||
clearError();
|
||||
start();
|
||||
if (fetchError) {
|
||||
handleError('Cannot update Apprise services. Failed to fetch the initial list.');
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/updateAppriseServices', {
|
||||
const response = await fetch('/api/v1/notif/apprise/services', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -69,20 +71,14 @@ export default function AppriseURLs() {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setFormIsLoading(false);
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
handleError(result.message);
|
||||
} else {
|
||||
setFormIsLoading(false);
|
||||
setUrlsFormIsSaved(true);
|
||||
setTimeout(() => setUrlsFormIsSaved(false), 3000);
|
||||
handleSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
setFormIsLoading(false);
|
||||
setError('Failed to update your services. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
handleError('Failed to update your Apprise services.');
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -91,18 +87,8 @@ export default function AppriseURLs() {
|
|||
{/* APPRISE SERVICES URLS */}
|
||||
<div className={classes.headerFormAppriseUrls}>
|
||||
<div style={{ marginRight: '10px' }}>Apprise URLs</div>
|
||||
{error && <Error message={error} />}
|
||||
<div style={{ display: 'flex' }}>
|
||||
{formIsLoading && (
|
||||
<SpinnerCircularFixed
|
||||
size={18}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
)}
|
||||
{urlsFormIsSaved && (
|
||||
{isSaved && (
|
||||
<div className={classes.formIsSavedMessage}>
|
||||
✅ Apprise configuration has been saved.
|
||||
</div>
|
||||
|
|
@ -115,7 +101,6 @@ export default function AppriseURLs() {
|
|||
>
|
||||
<textarea
|
||||
style={{ height: '100px' }}
|
||||
type='text'
|
||||
placeholder={
|
||||
'matrixs://{user}:{password}@{matrixhost}\ndiscord://{WebhookID}/{WebhookToken}\nmmosts://user@hostname/authkey'
|
||||
}
|
||||
|
|
@ -151,6 +136,7 @@ export default function AppriseURLs() {
|
|||
</a>{' '}
|
||||
to send a notification to any service. Only one URL per line.
|
||||
</div>
|
||||
{error && <Error message={error} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Switch from '../../../Components/UI/Switch/Switch';
|
||||
|
||||
export default function EmailAlertSettings() {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
//Callback > re-enabled button after notification.
|
||||
onClose: () => setDisabled(false),
|
||||
};
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [testIsLoading, setTestIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [checked, setChecked] = useState();
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getEmailAlert', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setChecked((await response.json()).emailAlert);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setError('Fetching email alert setting failed. Contact your administrator.');
|
||||
console.log('Fetching email alert setting failed.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
dataFetch();
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Switch to enable/disable Email notifications
|
||||
const onChangeSwitchHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Disabled button
|
||||
setDisabled(true);
|
||||
await fetch('/api/account/updateEmailAlert', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
if (response.ok) {
|
||||
if (data.emailAlert) {
|
||||
setChecked(!checked);
|
||||
toast.success('Email notification enabled !', toastOptions);
|
||||
} else {
|
||||
setChecked(!checked);
|
||||
toast.success('Email notification disabled !', toastOptions);
|
||||
}
|
||||
} else {
|
||||
setError('Update email alert setting failed.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
setDisabled(false);
|
||||
}, 4000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setError('Update failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
setDisabled(false);
|
||||
}, 4000);
|
||||
});
|
||||
};
|
||||
|
||||
//Send a test notification by email
|
||||
const onSendTestMailHandler = async () => {
|
||||
//Loading
|
||||
setTestIsLoading(true);
|
||||
//Remove old error
|
||||
setError();
|
||||
await fetch('/api/account/sendTestEmail', {
|
||||
method: 'POST',
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
setTestIsLoading(false);
|
||||
setError('Failed to send the notification.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
} else {
|
||||
setTestIsLoading(false);
|
||||
setInfo(true);
|
||||
setTimeout(() => {
|
||||
setInfo(false);
|
||||
}, 4000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setTestIsLoading(false);
|
||||
console.log(error);
|
||||
setError('Send email failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* EMAIL ALERT */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Email alert</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/user-manual/account/#alerting'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
{isLoading ? (
|
||||
<SpinnerCircularFixed
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
switchName='Alert me by email'
|
||||
switchDescription='You will receive an alert every 24H if you have a down status.'
|
||||
onChange={(e) => onChangeSwitchHandler({ emailAlert: e })}
|
||||
/>
|
||||
)}
|
||||
{testIsLoading ? (
|
||||
<SpinnerCircularFixed
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
) : (
|
||||
<button className='defaultButton' onClick={onSendTestMailHandler}>
|
||||
Send a test mail
|
||||
</button>
|
||||
)}
|
||||
{info && (
|
||||
<span style={{ marginLeft: '10px', color: '#119300' }}>Mail successfully sent.</span>
|
||||
)}
|
||||
{error && <Error message={error} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { EmailAlertDTO, Optional } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import Switch from '~/Components/UI/Switch/Switch';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
|
||||
export default function EmailAlertSettings() {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
//Callback > re-enabled button after notification.
|
||||
onClose: () => setIsSwitchDisabled(false),
|
||||
};
|
||||
|
||||
const { error, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
////State
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
const [isSwitchDisabled, setIsSwitchDisabled] = useState(true);
|
||||
const [isAlertEnabled, setIsAlertEnabled] = useState<Optional<boolean>>(undefined);
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/email/alert', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data: Optional<EmailAlertDTO> = await response.json();
|
||||
setIsAlertEnabled(data?.emailAlert ?? false);
|
||||
setIsSwitchDisabled(false);
|
||||
} catch (error) {
|
||||
setIsSwitchDisabled(true);
|
||||
setIsAlertEnabled(false);
|
||||
handleError('Fetching email alert setting failed');
|
||||
}
|
||||
};
|
||||
dataFetch();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Switch to enable/disable Email notifications
|
||||
const onChangeSwitchHandler = async (data: EmailAlertDTO) => {
|
||||
clearError();
|
||||
start();
|
||||
setIsSwitchDisabled(true);
|
||||
await fetch('/api/v1/notif/email/alert', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok && typeof data.emailAlert === 'boolean') {
|
||||
setIsAlertEnabled(data.emailAlert);
|
||||
toast.success(
|
||||
data.emailAlert ? 'Email notification enabled !' : 'Email notification disabled !',
|
||||
toastOptions
|
||||
);
|
||||
} else {
|
||||
handleError('Update email alert setting failed.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
handleError('Update email alert setting failed.');
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsSwitchDisabled(false);
|
||||
});
|
||||
};
|
||||
|
||||
//Send a test notification by email
|
||||
const onSendTestMailHandler = async () => {
|
||||
clearError();
|
||||
start();
|
||||
setIsSendingTestNotification(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/email/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsSendingTestNotification(false);
|
||||
handleError(result.message);
|
||||
} else {
|
||||
setIsSendingTestNotification(false);
|
||||
setInfo(true);
|
||||
setTimeout(() => {
|
||||
setInfo(false);
|
||||
}, 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsSendingTestNotification(false);
|
||||
handleError('Send notification failed');
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* EMAIL ALERT */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Email alert</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/user-manual/account/#alerting'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
<Switch
|
||||
loading={isAlertEnabled === undefined}
|
||||
checked={isAlertEnabled}
|
||||
disabled={isSwitchDisabled}
|
||||
switchName='Alert me by email'
|
||||
switchDescription='You will receive an alert every 24H if you have a down status.'
|
||||
onChange={(e) => onChangeSwitchHandler({ emailAlert: e })}
|
||||
/>
|
||||
|
||||
<button
|
||||
className='defaultButton'
|
||||
disabled={isSendingTestNotification}
|
||||
onClick={onSendTestMailHandler}
|
||||
>
|
||||
Send a test mail
|
||||
</button>
|
||||
{info && (
|
||||
<span style={{ marginLeft: '10px', color: '#119300' }}>Mail successfully sent.</span>
|
||||
)}
|
||||
{error && <Error message={error} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Info from '../../../Components/UI/Info/Info';
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import Info from '~/Components/UI/Info/Info';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { EmailSettingDTO } from '~/types/api/setting.types';
|
||||
|
||||
export default function EmailSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
export default function EmailSettings(props: EmailSettingDTO) {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -26,24 +26,23 @@ export default function EmailSettings(props) {
|
|||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<EmailSettingDTO>({ mode: 'onChange' });
|
||||
|
||||
const { isLoading, error, setIsLoading, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////Functions
|
||||
//Form submit Handler for ADD a repo
|
||||
const formSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
const formSubmitHandler = async (data: EmailSettingDTO) => {
|
||||
start();
|
||||
clearError();
|
||||
setIsLoading(true);
|
||||
//POST API to send the new mail address
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/updateEmail', {
|
||||
const response = await fetch('/api/v1/account/email', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -53,10 +52,8 @@ export default function EmailSettings(props) {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
handleError(result.message);
|
||||
} else {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
|
|
@ -65,9 +62,10 @@ export default function EmailSettings(props) {
|
|||
}
|
||||
} catch (error) {
|
||||
reset();
|
||||
handleError('Updating your email failed.');
|
||||
} finally {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
setError("Can't update your email. Contact your administrator.");
|
||||
setTimeout(() => setError(), 4000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
|
@ -109,13 +107,9 @@ export default function EmailSettings(props) {
|
|||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={!isValid || isSubmitting}
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={20} thickness={150} speed={100} color='#fff' />
|
||||
) : (
|
||||
'Update your email'
|
||||
)}
|
||||
Update your email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
|
@ -1,23 +1,26 @@
|
|||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { IconExternalLink, IconTrash } from '@tabler/icons-react';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import timestampConverter from '../../../helpers/functions/timestampConverter';
|
||||
import { IconTrash, IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { IntegrationTokenType, Optional, TokenPermissionEnum, TokenPermissionsType } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import CopyButton from '../../../Components/UI/CopyButton/CopyButton';
|
||||
import Info from '../../../Components/UI/Info/Info';
|
||||
import CopyButton from '~/Components/UI/CopyButton/CopyButton';
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import Info from '~/Components/UI/Info/Info';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
|
||||
type IntegrationsDataForm = {
|
||||
tokenName: string;
|
||||
};
|
||||
|
||||
export default function Integrations() {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -32,16 +35,28 @@ export default function Integrations() {
|
|||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
} = useForm<IntegrationsDataForm>({ mode: 'onChange' });
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const { error, handleError, clearError, setIsLoading, isLoading } = useFormStatus();
|
||||
|
||||
const renderPermissionBadges = (permissions: TokenPermissionsType) => {
|
||||
return Object.entries(permissions)
|
||||
.filter(([, hasPermission]) => hasPermission)
|
||||
.map(([key]) => (
|
||||
<div key={key} className={classes.permissionBadge}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [tokenList, setTokenList] = useState([]);
|
||||
const [error, setError] = useState();
|
||||
const [lastGeneratedToken, setLastGeneratedToken] = useState();
|
||||
const [deletingToken, setDeletingToken] = useState(null);
|
||||
const [permissions, setPermissions] = useState({
|
||||
const [tokenList, setTokenList] = useState<Array<IntegrationTokenType>>();
|
||||
const [lastGeneratedToken, setLastGeneratedToken] =
|
||||
useState<Optional<{ name: string; value: string }>>();
|
||||
const [deletingToken, setDeletingToken] = useState<Optional<IntegrationTokenType>>(undefined);
|
||||
const [permissions, setPermissions] = useState<TokenPermissionsType>({
|
||||
create: false,
|
||||
read: false,
|
||||
update: false,
|
||||
|
|
@ -49,30 +64,34 @@ export default function Integrations() {
|
|||
});
|
||||
|
||||
const fetchTokenList = async () => {
|
||||
start();
|
||||
try {
|
||||
const response = await fetch('/api/account/tokenManager', {
|
||||
const response = await fetch('/api/v1/integration/token-manager', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const tokensArray = await response.json();
|
||||
setTokenList(tokensArray);
|
||||
const data: Array<IntegrationTokenType> = await response.json();
|
||||
setTokenList(data);
|
||||
} catch (error) {
|
||||
console.log('Fetching token list failed.');
|
||||
handleError('Fetching token list failed.');
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
////LifeCycle
|
||||
useEffect(() => {
|
||||
fetchTokenList();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Permissions handler
|
||||
const hasNoPermissionSelected = () => {
|
||||
return !Object.values(permissions).some((value) => value);
|
||||
};
|
||||
const togglePermission = (permissionType) => {
|
||||
const togglePermission = (permissionType: TokenPermissionEnum) => {
|
||||
const updatedPermissions = {
|
||||
...permissions,
|
||||
[permissionType]: !permissions[permissionType],
|
||||
|
|
@ -88,60 +107,48 @@ export default function Integrations() {
|
|||
});
|
||||
};
|
||||
|
||||
//Form submit Handler for ADD a new token
|
||||
const formSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
//Form submit handler to ADD a new token
|
||||
const formSubmitHandler = async (data: IntegrationsDataForm) => {
|
||||
start();
|
||||
clearError();
|
||||
setIsLoading(true);
|
||||
//Generate a UUIDv4
|
||||
const token = uuidv4();
|
||||
setLastGeneratedToken({ name: data.tokenName, value: token });
|
||||
|
||||
// Post API to send the new token integration
|
||||
try {
|
||||
const response = await fetch('/api/account/tokenManager', {
|
||||
const response = await fetch('/api/v1/integration/token-manager', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: data.tokenName,
|
||||
token: token,
|
||||
creation: Math.floor(Date.now() / 1000),
|
||||
expiration: null,
|
||||
permissions: permissions,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
setLastGeneratedToken({ name: data.tokenName, value: result.token });
|
||||
|
||||
if (!response.ok) {
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
resetPermissions();
|
||||
toast.error(result.message, toastOptions);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
reset();
|
||||
resetPermissions();
|
||||
fetchTokenList();
|
||||
setIsLoading(false);
|
||||
toast.success('🔑 Token generated !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
reset();
|
||||
resetPermissions();
|
||||
toast.error('Failed to generate a new token', toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
toast.error("Can't generate your token. Contact your administrator.", toastOptions);
|
||||
setTimeout(() => setError(), 4000);
|
||||
resetPermissions();
|
||||
reset();
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
//Delete token
|
||||
const deleteTokenHandler = async (tokenName) => {
|
||||
const deleteTokenHandler = async (tokenName: string) => {
|
||||
setIsDeleteLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/account/tokenManager', {
|
||||
const response = await fetch('/api/v1/integration/token-manager', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -154,7 +161,6 @@ export default function Integrations() {
|
|||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
setTimeout(() => setError(), 4000);
|
||||
setIsDeleteLoading(false);
|
||||
} else {
|
||||
fetchTokenList();
|
||||
|
|
@ -163,11 +169,10 @@ export default function Integrations() {
|
|||
}
|
||||
} catch (error) {
|
||||
setIsDeleteLoading(false);
|
||||
toast.error("Can't delete your token. Contact your administrator.", toastOptions);
|
||||
setTimeout(() => setError(), 4000);
|
||||
toast.error('Failed to delete the token', toastOptions);
|
||||
} finally {
|
||||
setIsDeleteLoading(false);
|
||||
setDeletingToken(null);
|
||||
setDeletingToken(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -205,25 +210,25 @@ export default function Integrations() {
|
|||
<div className={classes.permissionsWrapper}>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.create ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission('create')}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.CREATE)}
|
||||
>
|
||||
Create
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.read ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission('read')}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.READ)}
|
||||
>
|
||||
Read
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.update ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission('update')}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.UPDATE)}
|
||||
>
|
||||
Update
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.delete ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission('delete')}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.DELETE)}
|
||||
>
|
||||
Delete
|
||||
</div>
|
||||
|
|
@ -234,11 +239,7 @@ export default function Integrations() {
|
|||
className={classes.AccountSettingsButton}
|
||||
disabled={!isValid || isSubmitting || hasNoPermissionSelected()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={15} thickness={150} speed={100} color='#fff' />
|
||||
) : (
|
||||
'Generate'
|
||||
)}
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
{errors.tokenName && errors.tokenName.type === 'maxLength' && (
|
||||
|
|
@ -272,25 +273,19 @@ export default function Integrations() {
|
|||
>
|
||||
<div className={classes.tokenCardHeader}>{token.name}</div>
|
||||
<div className={classes.tokenCardBody}>
|
||||
<p>
|
||||
<div className={classes.tokenInfo}>
|
||||
<strong>Created at:</strong>
|
||||
{timestampConverter(token.creation)}
|
||||
</p>
|
||||
<p>
|
||||
{fromUnixTime(token.creation).toLocaleString()}
|
||||
</div>
|
||||
<div className={classes.tokenInfo}>
|
||||
<strong>Permission:</strong>
|
||||
<div className={classes.permissionBadges}>
|
||||
{Object.keys(token.permissions).map((permission) =>
|
||||
token.permissions[permission] ? (
|
||||
<div key={permission} className={classes.permissionBadge}>
|
||||
{permission.charAt(0).toUpperCase() + permission.slice(1)}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
{renderPermissionBadges(token.permissions)}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
{lastGeneratedToken && lastGeneratedToken.name === token.name && (
|
||||
<>
|
||||
<p>
|
||||
<div className={classes.tokenInfo}>
|
||||
<strong>Token:</strong>
|
||||
<CopyButton
|
||||
size={22}
|
||||
|
|
@ -299,10 +294,11 @@ export default function Integrations() {
|
|||
>
|
||||
<span>{lastGeneratedToken.value}</span>
|
||||
</CopyButton>
|
||||
</p>
|
||||
<Info color='#3498db'>
|
||||
This token will not be shown again. Please save it.
|
||||
</Info>
|
||||
</div>
|
||||
<Info
|
||||
color='#3498db'
|
||||
message='This token will not be shown again. Please save it.'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{deletingToken && deletingToken.name === token.name && (
|
||||
|
|
@ -313,14 +309,11 @@ export default function Integrations() {
|
|||
disabled={isDeleteLoading}
|
||||
>
|
||||
Confirm
|
||||
{isDeleteLoading && (
|
||||
<SpinnerDotted size={15} thickness={150} speed={100} color='#fff' />
|
||||
)}{' '}
|
||||
</button>
|
||||
{!isDeleteLoading && (
|
||||
<button
|
||||
className={classes.cancelButton}
|
||||
onClick={() => setDeletingToken(null)}
|
||||
onClick={() => setDeletingToken(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { PasswordSettingDTO } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
|
||||
export default function PasswordSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
export default function PasswordSettings() {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -25,24 +23,19 @@ export default function PasswordSettings(props) {
|
|||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
formState: { isSubmitting },
|
||||
} = useForm<PasswordSettingDTO>({ mode: 'onChange' });
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const { isLoading, setIsLoading } = useFormStatus();
|
||||
|
||||
////Functions
|
||||
//Form submit Handler for ADD a repo
|
||||
const formSubmitHandler = async (data) => {
|
||||
console.log(data);
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
const formSubmitHandler = async (data: PasswordSettingDTO) => {
|
||||
start();
|
||||
setIsLoading(true);
|
||||
//POST API to send the new and old password
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/updatePassword', {
|
||||
const response = await fetch('/api/v1/account/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -52,20 +45,16 @@ export default function PasswordSettings(props) {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
toast.error(result.message, toastOptions);
|
||||
} else {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
toast.success('🔑 Password edited !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update password. Please try again.', toastOptions);
|
||||
} finally {
|
||||
stop();
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
setError("Can't update your password. Contact your administrator.");
|
||||
setTimeout(() => setError(), 4000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
|
@ -78,7 +67,6 @@ export default function PasswordSettings(props) {
|
|||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
<form onSubmit={handleSubmit(formSubmitHandler)} className={classes.bwForm}>
|
||||
{error && <Error message={error} />}
|
||||
<p>
|
||||
<input
|
||||
type='password'
|
||||
|
|
@ -87,9 +75,6 @@ export default function PasswordSettings(props) {
|
|||
required: true,
|
||||
})}
|
||||
/>
|
||||
{errors.oldPassword && errors.oldPassword.type === 'required' && (
|
||||
<small className={classes.errorMessage}>This field is required.</small>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<input
|
||||
|
|
@ -99,16 +84,12 @@ export default function PasswordSettings(props) {
|
|||
required: true,
|
||||
})}
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<small className={classes.errorMessage}>This field is required.</small>
|
||||
)}
|
||||
</p>
|
||||
<button className={classes.AccountSettingsButton} disabled={!isValid || isSubmitting}>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={20} thickness={150} speed={100} color='#fff' />
|
||||
) : (
|
||||
'Update your password'
|
||||
)}
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
Update your password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
//Lib
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from './UserSettings.module.css';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
//Components
|
||||
import EmailSettings from './EmailSettings/EmailSettings';
|
||||
import PasswordSettings from './PasswordSettings/PasswordSettings';
|
||||
import UsernameSettings from './UsernameSettings/UsernameSettings';
|
||||
import EmailAlertSettings from './EmailAlertSettings/EmailAlertSettings';
|
||||
import AppriseAlertSettings from './AppriseAlertSettings/AppriseAlertSettings';
|
||||
import Integrations from './Integrations/Integrations';
|
||||
|
||||
export default function UserSettings(props) {
|
||||
//States
|
||||
const [tab, setTab] = useState('General');
|
||||
const [wizardEnv, setWizardEnv] = useState({});
|
||||
|
||||
//ComponentDidMount
|
||||
useEffect(() => {
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getWizardEnv', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setWizardEnv((await response.json()).wizardEnv);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classes.containerSettings}>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
color: '#494b7a',
|
||||
textAlign: 'left',
|
||||
marginLeft: '30px',
|
||||
}}
|
||||
>
|
||||
Account{' '}
|
||||
</h1>
|
||||
</div>
|
||||
<div className={classes.tabList}>
|
||||
<button
|
||||
className={tab === 'General' ? classes.tabListButtonActive : classes.tabListButton}
|
||||
onClick={() => setTab('General')}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={tab === 'Notifications' ? classes.tabListButtonActive : classes.tabListButton}
|
||||
onClick={() => setTab('Notifications')}
|
||||
>
|
||||
Notifications
|
||||
</button>
|
||||
{wizardEnv.DISABLE_INTEGRATIONS !== 'true' && (
|
||||
<button
|
||||
className={tab === 'Integrations' ? classes.tabListButtonActive : classes.tabListButton}
|
||||
onClick={() => setTab('Integrations')}
|
||||
>
|
||||
Integrations
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{tab === 'General' && (
|
||||
<>
|
||||
<PasswordSettings username={props.data.user.name} />
|
||||
<EmailSettings email={props.data.user.email} />
|
||||
<UsernameSettings username={props.data.user.name} />{' '}
|
||||
</>
|
||||
)}
|
||||
{tab === 'Notifications' && (
|
||||
<>
|
||||
<EmailAlertSettings />
|
||||
<AppriseAlertSettings />
|
||||
</>
|
||||
)}
|
||||
{tab === 'Integrations' && (
|
||||
<>
|
||||
<Integrations />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
align-content: baseline;
|
||||
}
|
||||
|
||||
.tokenCardBody p {
|
||||
.tokenInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
|
@ -387,7 +387,7 @@
|
|||
.headerFormAppriseUrls {
|
||||
font-weight: 500;
|
||||
color: #494b7a;
|
||||
margin: 40px 0px 10px 0px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
|
|
|||
100
Containers/UserSettings/UserSettings.tsx
Normal file
100
Containers/UserSettings/UserSettings.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from './UserSettings.module.css';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Session } from 'next-auth';
|
||||
import { Optional, WizardEnvType, SessionStatus } from '~/types';
|
||||
|
||||
// Components
|
||||
import EmailSettings from './EmailSettings/EmailSettings';
|
||||
import PasswordSettings from './PasswordSettings/PasswordSettings';
|
||||
import UsernameSettings from './UsernameSettings/UsernameSettings';
|
||||
import EmailAlertSettings from './EmailAlertSettings/EmailAlertSettings';
|
||||
import AppriseAlertSettings from './AppriseAlertSettings/AppriseAlertSettings';
|
||||
import Integrations from './Integrations/Integrations';
|
||||
|
||||
type UserSettingsProps = {
|
||||
status: SessionStatus;
|
||||
data: Session;
|
||||
};
|
||||
|
||||
export default function UserSettings({ data }: UserSettingsProps) {
|
||||
const [tab, setTab] = useState<'General' | 'Notifications' | 'Integrations'>('General');
|
||||
const [wizardEnv, setWizardEnv] = useState<Optional<WizardEnvType>>(undefined);
|
||||
|
||||
// Fetch wizard environment on mount
|
||||
useEffect(() => {
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/wizard-env');
|
||||
const data: WizardEnvType = await response.json();
|
||||
setWizardEnv(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch wizard environment:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
|
||||
// If Integrations tab is selected but disabled, fallback to General
|
||||
useEffect(() => {
|
||||
if (tab === 'Integrations' && wizardEnv?.DISABLE_INTEGRATIONS === 'true') {
|
||||
setTab('General');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wizardEnv?.DISABLE_INTEGRATIONS]);
|
||||
|
||||
return (
|
||||
<div className={classes.containerSettings}>
|
||||
<h1 style={{ color: '#494b7a', textAlign: 'left', marginLeft: '30px' }}>Account</h1>
|
||||
|
||||
{wizardEnv != undefined && (
|
||||
<>
|
||||
<div className={classes.tabList}>
|
||||
<button
|
||||
className={tab === 'General' ? classes.tabListButtonActive : classes.tabListButton}
|
||||
onClick={() => setTab('General')}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
tab === 'Notifications' ? classes.tabListButtonActive : classes.tabListButton
|
||||
}
|
||||
onClick={() => setTab('Notifications')}
|
||||
>
|
||||
Notifications
|
||||
</button>
|
||||
{wizardEnv.DISABLE_INTEGRATIONS !== 'true' && (
|
||||
<button
|
||||
className={
|
||||
tab === 'Integrations' ? classes.tabListButtonActive : classes.tabListButton
|
||||
}
|
||||
onClick={() => setTab('Integrations')}
|
||||
>
|
||||
Integrations
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === 'General' && (
|
||||
<>
|
||||
<PasswordSettings />
|
||||
<EmailSettings email={data.user?.email ?? undefined} />
|
||||
<UsernameSettings username={data.user?.name ?? undefined} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'Notifications' && (
|
||||
<>
|
||||
<EmailAlertSettings />
|
||||
<AppriseAlertSettings />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'Integrations' && wizardEnv.DISABLE_INTEGRATIONS !== 'true' && <Integrations />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { UsernameSettingDTO } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Info from '../../../Components/UI/Info/Info';
|
||||
import Info from '~/Components/UI/Info/Info';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
|
||||
export default function UsernameSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
export default function UsernameSettings(props: UsernameSettingDTO) {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -26,24 +25,22 @@ export default function UsernameSettings(props) {
|
|||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UsernameSettingDTO>({ mode: 'onChange' });
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const { isLoading, setIsLoading } = useFormStatus();
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////Functions
|
||||
//Form submit Handler for ADD a repo
|
||||
const formSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
const formSubmitHandler = async (data: UsernameSettingDTO) => {
|
||||
start();
|
||||
setIsLoading(true);
|
||||
//POST API to update the username
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/updateUsername', {
|
||||
const response = await fetch('/api/v1/account/username', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -53,21 +50,17 @@ export default function UsernameSettings(props) {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
toast.error(result.message, toastOptions);
|
||||
} else {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
setInfo(true);
|
||||
toast.success('Username edited !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update username. Please try again.', toastOptions);
|
||||
} finally {
|
||||
reset();
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
setError("Can't update your username. Contact your administrator.");
|
||||
setTimeout(() => setError(), 4000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
|
@ -84,30 +77,29 @@ export default function UsernameSettings(props) {
|
|||
//at the time this code is written to refresh client-side session information
|
||||
//without triggering a logout.
|
||||
//I chose to inform the user to reconnect rather than force logout.
|
||||
<Info message='Please, logout to update your session.' />
|
||||
<Info message='Please, logout to update your session' />
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(formSubmitHandler)}
|
||||
className={classes.bwForm + ' ' + classes.currentSetting}
|
||||
>
|
||||
<p>
|
||||
{error && <Error message={error} />}
|
||||
<input
|
||||
type='text'
|
||||
placeholder={props.username}
|
||||
{...register('username', {
|
||||
required: 'A username is required.',
|
||||
pattern: {
|
||||
value: /^[a-z]{5,15}$/,
|
||||
message: 'Only a-z characters are allowed.',
|
||||
value: /^[a-z]{1,40}$/,
|
||||
message: 'Only a-z characters are allowed',
|
||||
},
|
||||
maxLength: {
|
||||
value: 10,
|
||||
message: '15 characters max.',
|
||||
value: 40,
|
||||
message: '40 characters max.',
|
||||
},
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: '5 characters min.',
|
||||
value: 1,
|
||||
message: '1 characters min.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
@ -117,13 +109,9 @@ export default function UsernameSettings(props) {
|
|||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={!isValid || isSubmitting}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={20} thickness={150} speed={100} color='#fff' />
|
||||
) : (
|
||||
'Update your username'
|
||||
)}
|
||||
Update your username
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
19
Dockerfile
19
Dockerfile
|
|
@ -6,23 +6,29 @@ FROM node:22-bookworm-slim as base
|
|||
# build stage
|
||||
FROM base AS deps
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN npm ci --omit=dev
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN sed -i "s/images:/output: 'standalone',images:/" next.config.js
|
||||
RUN sed -i "s/images:/output: 'standalone',images:/" next.config.ts
|
||||
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
# run stage
|
||||
FROM base AS runner
|
||||
|
|
@ -35,7 +41,7 @@ ENV HOSTNAME=
|
|||
|
||||
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
|
||||
RUN apt-get update && apt-get install -y \
|
||||
supervisor curl jq jc borgbackup/bookworm-backports openssh-server rsyslog && \
|
||||
supervisor curl jq jc borgbackup/bookworm-backports openssh-server && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -g ${GID} borgwarehouse && useradd -m -u ${UID} -g ${GID} borgwarehouse
|
||||
|
|
@ -50,7 +56,6 @@ COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/.next/standalone ./
|
|||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/public ./public
|
||||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/docker/supervisord.conf ./
|
||||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/docker/rsyslog.conf /etc/rsyslog.conf
|
||||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/docker/sshd_config ./
|
||||
|
||||
USER borgwarehouse
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -1,16 +1,19 @@
|
|||
<div align="center">
|
||||
|
||||
[![TypeScript][typescript.js]][typescript-url]
|
||||
[![Next][Next.js]][Next-url]
|
||||
[![React][React.js]][React-url]
|
||||
|
||||
</div>
|
||||
<div align="center">
|
||||
|
||||
[](https://hub.docker.com/r/borgwarehouse/borgwarehouse)
|
||||
[](https://hub.docker.com/r/borgwarehouse/borgwarehouse)
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<h3 align="center">BorgWarehouse</h3>
|
||||
<img src="public/borgwarehouse-logo-violet.svg" alt="BorgWarehouse" style="margin: 30px 0">
|
||||
|
||||
<p align="center">
|
||||
A fast and modern WebUI for a BorgBackup's central repository server.
|
||||
|
|
@ -20,17 +23,17 @@
|
|||
|
||||
<div align="center">
|
||||
<a href="https://borgwarehouse.com">
|
||||
<img src="medias/borgwarehouse-og.png" alt="presentation">
|
||||
<img src="medias/borgwarehouse-og.jpg" alt="presentation">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## ⭐ Support the Project
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/sponsors/Ravinou"><img alt="GitHub Sponsors" src="https://img.shields.io/github/sponsors/Ravinou?style=for-the-badge&logo=github&label=Github%20Sponsors&link=https%3A%2F%2Fgithub.com%2Fsponsors%2FRavinou"></a>
|
||||
<a href="https://liberapay.com/R4VEN/"><img alt="Liberapay patrons" src="https://img.shields.io/liberapay/patrons/R4VEN?style=for-the-badge&logo=liberapay&label=Liberapay%20Sponsors&link=https%3A%2F%2Fliberapay.com%2FR4VEN"></a>
|
||||
</div>
|
||||
|
||||
|
||||
If you find BorgWarehouse helpful or interesting, please consider **giving it a star on GitHub** and **[sponsoring](https://github.com/sponsors/Ravinou)**. Your support is greatly appreciated!
|
||||
|
||||
## ✨ What is BorgWarehouse ?
|
||||
|
|
@ -41,13 +44,14 @@ Today, if you want to have a large server on which you centralize backups of Bor
|
|||
|
||||
With BorgWarehouse, you have an interface that allows you to do all this simply and quickly :
|
||||
|
||||
- **add** repositories
|
||||
- **edit** existing repositories
|
||||
- **delete** repositories
|
||||
- be **alerted** if there are no recent backups
|
||||
- **monitor** the volume of data
|
||||
- **flexibly manage quotas** for each repository
|
||||
- ...
|
||||
- **add** repositories
|
||||
- **edit** existing repositories
|
||||
- **delete** repositories
|
||||
- be **alerted** if there are no recent backups
|
||||
- **monitor** the volume of data
|
||||
- **flexibly manage quotas** for each repository
|
||||
- manage everything you want through the **REST API**
|
||||
- ...
|
||||
|
||||
The whole system part is automatically managed by BorgWarehouse and **you don't have to touch your terminal anymore** while enjoying a visual feedback on the status of your repositories.
|
||||
|
||||
|
|
@ -71,13 +75,22 @@ Check the online documentation [just here](https://borgwarehouse.com/docs/admin-
|
|||
## ❤️ Special thanks to sponsors ❤️
|
||||
|
||||
### 🥇 Current sponsors 🥇
|
||||
|
||||
<a href="https://github.com/royalmoose"><img src="https://avatars.githubusercontent.com/royalmoose" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/Magneticdud"><img src="https://avatars.githubusercontent.com/Magneticdud" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/dhenry123"><img src="https://avatars.githubusercontent.com/dhenry123" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/fphammerle"><img src="https://avatars.githubusercontent.com/fphammerle" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/MacH59-cos"><img src="https://avatars.githubusercontent.com/MacH59-cos" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/shrippen"><img src="https://avatars.githubusercontent.com/shrippen" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/daschmidt1994"><img src="https://avatars.githubusercontent.com/daschmidt1994" style="width:50px; border-radius:50%;"/></a>
|
||||
|
||||
#### Past sponsors
|
||||
<a href="https://github.com/shad-lp"><img src="https://avatars.githubusercontent.com/shad-lp" style="width:25px; border-radius:50%;"/></a>
|
||||
|
||||
<a href="https://github.com/Drallibor"><img src="https://avatars.githubusercontent.com/Drallibor" style="width:25px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/shad-lp"><img src="https://avatars.githubusercontent.com/shad-lp" style="width:25px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/Magneticdud"><img src="https://avatars.githubusercontent.com/Magneticdud" style="width:25px; border-radius:50%;"/></a>
|
||||
|
||||
[typescript.js]: https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white
|
||||
[typescript-url]: https://www.typescriptlang.org/
|
||||
[next.js]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white
|
||||
[next-url]: https://nextjs.org/
|
||||
[react.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB
|
||||
|
|
|
|||
25
contexts/LoaderContext.tsx
Normal file
25
contexts/LoaderContext.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import NProgress from 'nprogress';
|
||||
|
||||
type LoaderContextType = {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const LoaderContext = createContext<LoaderContextType>({
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
export const LoaderProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const start = () => NProgress.start();
|
||||
const stop = () => NProgress.done();
|
||||
|
||||
return (
|
||||
<LoaderContext.Provider value={{ start, stop }}>
|
||||
{children}
|
||||
</LoaderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLoader = () => useContext(LoaderContext);
|
||||
|
|
@ -20,8 +20,6 @@ services:
|
|||
- ${SSH_PATH:?SSH_PATH variable missing}:/home/borgwarehouse/.ssh
|
||||
- ${SSH_HOST:?SSH_HOST variable missing}:/etc/ssh
|
||||
- ${BORG_REPOSITORY_PATH:?BORG_REPOSITORY_PATH variable missing}:/home/borgwarehouse/repos
|
||||
- ${TMP_PATH:?TMP_PATH variable missing}:/home/borgwarehouse/tmp
|
||||
- ${LOGS_PATH:?LOGS_PATH variable missing}:/home/borgwarehouse/logs
|
||||
# Apprise is used to send notifications, it's optional. http://apprise:8000 is the URL to use in BorgWarehouse.
|
||||
apprise:
|
||||
container_name: apprise
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
# rsyslog configuration file
|
||||
|
||||
$WorkDirectory /home/borgwarehouse/tmp
|
||||
$FileOwner borgwarehouse
|
||||
$FileGroup borgwarehouse
|
||||
$FileCreateMode 0640
|
||||
$DirCreateMode 0755
|
||||
$Umask 0022
|
||||
|
||||
$RepeatedMsgReduction on
|
||||
|
||||
module(load="imfile" PollingInterval="10")
|
||||
|
||||
input(type="imfile"
|
||||
File="/home/borgwarehouse/tmp/borgwarehouse.log"
|
||||
Tag="BorgWarehouse"
|
||||
Severity="info"
|
||||
Facility="local7"
|
||||
ruleset="bwLogs")
|
||||
|
||||
input(type="imfile"
|
||||
File="/home/borgwarehouse/tmp/sshd.log"
|
||||
Tag="sshd"
|
||||
Severity="info"
|
||||
Facility="local7"
|
||||
ruleset="sshdLogs")
|
||||
|
||||
$template myFormat,"%timegenerated:::date-rfc3339% %syslogtag% %msg%\n"
|
||||
|
||||
ruleset(name="bwLogs") {
|
||||
action(type="omfile"
|
||||
File="/home/borgwarehouse/logs/borgwarehouse.log"
|
||||
Template="myFormat")
|
||||
}
|
||||
|
||||
ruleset(name="sshdLogs") {
|
||||
action(type="omfile"
|
||||
File="/home/borgwarehouse/logs/sshd.log"
|
||||
Template="myFormat")
|
||||
}
|
||||
|
|
@ -1,24 +1,21 @@
|
|||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/home/borgwarehouse/logs/supervisord.log
|
||||
logfile=/dev/stdout
|
||||
logfile_maxbytes=0
|
||||
loglevel=error
|
||||
pidfile=/home/borgwarehouse/tmp/supervisord.pid
|
||||
logfile_maxbytes=10MB
|
||||
logfile_backups=5
|
||||
|
||||
[program:sshd]
|
||||
command=/usr/sbin/sshd -D -e -f /etc/ssh/sshd_config
|
||||
stdout_logfile=/home/borgwarehouse/tmp/sshd.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
redirect_stderr=false
|
||||
|
||||
[program:borgwarehouse]
|
||||
command=/usr/local/bin/node server.js
|
||||
stdout_logfile=/home/borgwarehouse/tmp/borgwarehouse.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
redirect_stderr=true
|
||||
|
||||
[program:rsyslogd]
|
||||
command=rsyslogd -n -i /home/borgwarehouse/tmp/rsyslogd.pid -f /etc/rsyslog.conf
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
redirect_stderr=false
|
||||
16
eslint.config.mjs
Normal file
16
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
12
helpers/functions/__mocks__/apiResponse.ts
Normal file
12
helpers/functions/__mocks__/apiResponse.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
const ApiResponse = {
|
||||
success: vi.fn(),
|
||||
badRequest: vi.fn(),
|
||||
unauthorized: vi.fn(),
|
||||
forbidden: vi.fn(),
|
||||
notFound: vi.fn(),
|
||||
methodNotAllowed: vi.fn(),
|
||||
validationError: vi.fn(),
|
||||
serverError: vi.fn(),
|
||||
};
|
||||
|
||||
export default ApiResponse;
|
||||
71
helpers/functions/apiResponse.ts
Normal file
71
helpers/functions/apiResponse.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { NextApiResponse } from 'next';
|
||||
|
||||
const getErrorMessage = (error: unknown): any => {
|
||||
if (error instanceof Error) {
|
||||
const shellError = error as any;
|
||||
|
||||
// Handle shell errors
|
||||
if ('code' in shellError || 'stderr' in shellError || 'stdout' in shellError) {
|
||||
return {
|
||||
code: shellError.code ?? null,
|
||||
cmd: shellError.cmd ?? null,
|
||||
stderr: shellError.stderr ?? null,
|
||||
stdout: shellError.stdout ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'ENOENT') {
|
||||
return 'No such file or directory';
|
||||
}
|
||||
}
|
||||
|
||||
return 'API error, contact the administrator';
|
||||
};
|
||||
|
||||
export default class ApiResponse {
|
||||
static success<T>(res: NextApiResponse, message = 'Success', data?: T) {
|
||||
res.status(200).json({ status: 200, message, data });
|
||||
}
|
||||
|
||||
static badRequest(res: NextApiResponse, message = 'Bad Request') {
|
||||
res.status(400).json({ status: 400, message });
|
||||
}
|
||||
|
||||
static unauthorized(res: NextApiResponse, message = 'Unauthorized') {
|
||||
res.status(401).json({ status: 401, message });
|
||||
}
|
||||
|
||||
static forbidden(res: NextApiResponse, message = 'Forbidden') {
|
||||
res.status(403).json({ status: 403, message });
|
||||
}
|
||||
|
||||
static notFound(res: NextApiResponse, message = 'Not Found') {
|
||||
res.status(404).json({ status: 404, message });
|
||||
}
|
||||
|
||||
static methodNotAllowed(res: NextApiResponse, message = 'Method Not Allowed') {
|
||||
res.status(405).json({ status: 405, message });
|
||||
}
|
||||
|
||||
static validationError(res: NextApiResponse, message = 'Validation Error') {
|
||||
res.status(422).json({ status: 422, message });
|
||||
}
|
||||
|
||||
static conflict(res: NextApiResponse, message = 'Conflict') {
|
||||
res.status(409).json({ status: 409, message });
|
||||
}
|
||||
|
||||
static serverError(
|
||||
res: NextApiResponse,
|
||||
error: unknown,
|
||||
fallbackMessage = 'API error, contact the administrator'
|
||||
) {
|
||||
const message = getErrorMessage(error) || fallbackMessage;
|
||||
res.status(500).json({ status: 500, message });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
// This function is used to hash user passwords and to verify them with the bcryptjs library
|
||||
//Lib
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
|
||||
export async function hashPassword(password) {
|
||||
return await hash(password, 12);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password, hashedPassword) {
|
||||
return await compare(password, hashedPassword);
|
||||
}
|
||||
4
helpers/functions/index.ts
Normal file
4
helpers/functions/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import lanCommandOption from './lanCommandOption';
|
||||
import isSshPubKeyDuplicate from './isSshPubKeyDuplicate';
|
||||
|
||||
export { lanCommandOption, isSshPubKeyDuplicate };
|
||||
70
helpers/functions/isSshPubKeyDuplicate.test.ts
Normal file
70
helpers/functions/isSshPubKeyDuplicate.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import isSshPubKeyDuplicate from './isSshPubKeyDuplicate';
|
||||
import { Optional, Repository } from '~/types';
|
||||
|
||||
describe('isSshPubKeyDuplicate', () => {
|
||||
it('should return true if the SSH public key is duplicated', () => {
|
||||
const pubKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey user@hostname';
|
||||
const repoList: Array<Optional<Repository>> = [
|
||||
{ sshPublicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey other@host' } as Repository,
|
||||
];
|
||||
|
||||
expect(isSshPubKeyDuplicate(pubKey, repoList)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the SSH public key is not duplicated', () => {
|
||||
const pubKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAdifferentkey user@hostname';
|
||||
const repoList: Array<Optional<Repository>> = [
|
||||
{ sshPublicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey other@host' } as Repository,
|
||||
];
|
||||
|
||||
expect(isSshPubKeyDuplicate(pubKey, repoList)).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw an error if pubKey is missing', () => {
|
||||
const repoList: Array<Optional<Repository>> = [
|
||||
{ sshPublicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey other@host' } as Repository,
|
||||
];
|
||||
|
||||
expect(() => isSshPubKeyDuplicate('', repoList)).toThrow(
|
||||
'Missing or invalid parameters for duplicate SSH public key check.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if repoList is missing', () => {
|
||||
const pubKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey user@hostname';
|
||||
|
||||
expect(() => isSshPubKeyDuplicate(pubKey, null as any)).toThrow(
|
||||
'Missing or invalid parameters for duplicate SSH public key check.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false if repoList is empty', () => {
|
||||
const pubKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey user@hostname';
|
||||
const repoList: Array<Optional<Repository>> = [];
|
||||
|
||||
expect(isSshPubKeyDuplicate(pubKey, repoList)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle repositories with undefined sshPublicKey', () => {
|
||||
const pubKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey user@hostname';
|
||||
const repoList: Array<Optional<Repository>> = [
|
||||
// @ts-expect-error
|
||||
{ sshPublicKey: undefined } as Repository,
|
||||
{ sshPublicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey other@host' } as Repository,
|
||||
];
|
||||
|
||||
expect(isSshPubKeyDuplicate(pubKey, repoList)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle repositories with null sshPublicKey', () => {
|
||||
const pubKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey user@hostname';
|
||||
const repoList: Array<Optional<Repository>> = [
|
||||
// @ts-expect-error
|
||||
{ sshPublicKey: null } as Repository,
|
||||
{ sshPublicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAdifferentkey other@host' } as Repository,
|
||||
];
|
||||
|
||||
expect(isSshPubKeyDuplicate(pubKey, repoList)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { Optional, Repository } from '~/types';
|
||||
|
||||
/**
|
||||
* Checks if the given SSH public key is duplicated in the provided repository list by removing the comment part.
|
||||
*
|
||||
|
|
@ -6,7 +8,10 @@
|
|||
* @returns {boolean} - Returns true if the SSH public key is duplicated, otherwise false.
|
||||
* @throws {Error} - Throws an error if required parameters are missing or invalid.
|
||||
*/
|
||||
export default function isSshPubKeyDuplicate(pubKey, repoList) {
|
||||
export default function isSshPubKeyDuplicate(
|
||||
pubKey: string,
|
||||
repoList: Array<Optional<Repository>>
|
||||
): boolean {
|
||||
if (!pubKey || !repoList || !Array.isArray(repoList)) {
|
||||
throw new Error('Missing or invalid parameters for duplicate SSH public key check.');
|
||||
}
|
||||
|
|
@ -16,7 +21,7 @@ export default function isSshPubKeyDuplicate(pubKey, repoList) {
|
|||
|
||||
// Check if the normalized key is already in the repository list
|
||||
return repoList.some((repo) => {
|
||||
const repoSshKeyWithoutComment = repo.sshPublicKey.split(' ').slice(0, 2).join(' ');
|
||||
const repoSshKeyWithoutComment = repo?.sshPublicKey?.split(' ').slice(0, 2).join(' ');
|
||||
return repoSshKeyWithoutComment === pubKeyWithoutComment;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export default function lanCommandOption(wizardEnv, lanCommand) {
|
||||
let FQDN;
|
||||
let SSH_SERVER_PORT;
|
||||
if (lanCommand && wizardEnv.FQDN_LAN && wizardEnv.SSH_SERVER_PORT_LAN) {
|
||||
FQDN = wizardEnv.FQDN_LAN;
|
||||
SSH_SERVER_PORT = wizardEnv.HIDE_SSH_PORT === 'true' ? '' : ':' + wizardEnv.SSH_SERVER_PORT_LAN;
|
||||
} else {
|
||||
FQDN = wizardEnv.FQDN;
|
||||
SSH_SERVER_PORT = wizardEnv.HIDE_SSH_PORT === 'true' ? '' : ':' + wizardEnv.SSH_SERVER_PORT;
|
||||
}
|
||||
|
||||
return { FQDN, SSH_SERVER_PORT };
|
||||
}
|
||||
75
helpers/functions/lanCommandOption.test.ts
Normal file
75
helpers/functions/lanCommandOption.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import lanCommandOption from './lanCommandOption';
|
||||
import { WizardEnvType } from '~/types';
|
||||
|
||||
describe('lanCommandOption', () => {
|
||||
it('should return undefined values when wizardEnv is not provided', () => {
|
||||
const result = lanCommandOption();
|
||||
expect(result).toEqual({ FQDN: undefined, SSH_SERVER_PORT: undefined });
|
||||
});
|
||||
|
||||
it('should return FQDN and SSH_SERVER_PORT from wizardEnv when lanCommand is false', () => {
|
||||
const wizardEnv: Partial<WizardEnvType> = {
|
||||
FQDN: 'example.com',
|
||||
FQDN_LAN: 'lan.example.com',
|
||||
SSH_SERVER_PORT: '22',
|
||||
SSH_SERVER_PORT_LAN: '2222',
|
||||
HIDE_SSH_PORT: 'false',
|
||||
};
|
||||
|
||||
const result = lanCommandOption(wizardEnv, false);
|
||||
expect(result).toEqual({ FQDN: 'example.com', SSH_SERVER_PORT: ':22' });
|
||||
});
|
||||
|
||||
it('should return FQDN_LAN and SSH_SERVER_PORT_LAN from wizardEnv when lanCommand is true', () => {
|
||||
const wizardEnv: Partial<WizardEnvType> = {
|
||||
FQDN: 'example.com',
|
||||
FQDN_LAN: 'lan.example.com',
|
||||
SSH_SERVER_PORT: '22',
|
||||
SSH_SERVER_PORT_LAN: '2222',
|
||||
HIDE_SSH_PORT: 'false',
|
||||
};
|
||||
|
||||
const result = lanCommandOption(wizardEnv, true);
|
||||
expect(result).toEqual({ FQDN: 'lan.example.com', SSH_SERVER_PORT: ':2222' });
|
||||
});
|
||||
|
||||
it('should return undefined for SSH_SERVER_PORT when HIDE_SSH_PORT is true', () => {
|
||||
const wizardEnv: Partial<WizardEnvType> = {
|
||||
FQDN: 'example.com',
|
||||
FQDN_LAN: 'lan.example.com',
|
||||
SSH_SERVER_PORT: '22',
|
||||
SSH_SERVER_PORT_LAN: '2222',
|
||||
HIDE_SSH_PORT: 'true',
|
||||
};
|
||||
|
||||
const result = lanCommandOption(wizardEnv, false);
|
||||
expect(result).toEqual({ FQDN: 'example.com', SSH_SERVER_PORT: undefined });
|
||||
});
|
||||
|
||||
it('should fallback to FQDN and should leave ssh server port to undefined for some usages', () => {
|
||||
const wizardEnv: Partial<WizardEnvType> = {
|
||||
FQDN: 'example.com',
|
||||
FQDN_LAN: undefined,
|
||||
SSH_SERVER_PORT: '22',
|
||||
SSH_SERVER_PORT_LAN: undefined,
|
||||
HIDE_SSH_PORT: 'false',
|
||||
};
|
||||
|
||||
const result = lanCommandOption(wizardEnv, true);
|
||||
expect(result).toEqual({ FQDN: 'example.com', SSH_SERVER_PORT: undefined });
|
||||
});
|
||||
|
||||
it('should handle missing FQDN and SSH_SERVER_PORT gracefully', () => {
|
||||
const wizardEnv: Partial<WizardEnvType> = {
|
||||
FQDN: undefined,
|
||||
FQDN_LAN: 'lan.example.com',
|
||||
SSH_SERVER_PORT: undefined,
|
||||
SSH_SERVER_PORT_LAN: '2222',
|
||||
HIDE_SSH_PORT: 'false',
|
||||
};
|
||||
|
||||
const result = lanCommandOption(wizardEnv, false);
|
||||
expect(result).toEqual({ FQDN: undefined, SSH_SERVER_PORT: undefined });
|
||||
});
|
||||
});
|
||||
24
helpers/functions/lanCommandOption.ts
Normal file
24
helpers/functions/lanCommandOption.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Optional, WizardEnvType } from '~/types';
|
||||
|
||||
export default function lanCommandOption(
|
||||
wizardEnv?: Partial<WizardEnvType>,
|
||||
lanCommand?: boolean
|
||||
): { FQDN: Optional<string>; SSH_SERVER_PORT: Optional<string> } {
|
||||
if (!wizardEnv) {
|
||||
return { FQDN: undefined, SSH_SERVER_PORT: undefined };
|
||||
}
|
||||
|
||||
const { FQDN, FQDN_LAN, SSH_SERVER_PORT, SSH_SERVER_PORT_LAN, HIDE_SSH_PORT } = wizardEnv;
|
||||
|
||||
const isPortHidden = HIDE_SSH_PORT === 'true';
|
||||
|
||||
const selectedFQDN = lanCommand && FQDN_LAN ? FQDN_LAN : FQDN;
|
||||
const selectedPort = lanCommand ? SSH_SERVER_PORT_LAN : SSH_SERVER_PORT;
|
||||
|
||||
const formattedPort = !isPortHidden && selectedPort ? `:${selectedPort}` : undefined;
|
||||
|
||||
return {
|
||||
FQDN: selectedFQDN,
|
||||
SSH_SERVER_PORT: formattedPort,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
//Lib
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export default function nodemailerSMTP() {
|
||||
const transporter = nodemailer.createTransport({
|
||||
port: process.env.MAIL_SMTP_PORT,
|
||||
host: process.env.MAIL_SMTP_HOST,
|
||||
auth: {
|
||||
user: process.env.MAIL_SMTP_LOGIN,
|
||||
pass: process.env.MAIL_SMTP_PWD,
|
||||
},
|
||||
tls: {
|
||||
// do not fail on invalid certs >> allow self-signed or invalid TLS certificate
|
||||
rejectUnauthorized: process.env.MAIL_REJECT_SELFSIGNED_TLS,
|
||||
},
|
||||
});
|
||||
return transporter;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export default async function repoHistory(data) {
|
||||
try {
|
||||
const repoHistoryDir = path.join(process.cwd(), '/config/versions');
|
||||
const maxBackupCount = parseInt(process.env.MAX_REPO_BACKUP_COUNT) || 8;
|
||||
const timestamp = new Date().toISOString();
|
||||
const backupDate = timestamp.split('T')[0];
|
||||
|
||||
//Create the directory if it does not exist
|
||||
await fs.mkdir(repoHistoryDir, { recursive: true });
|
||||
|
||||
const existingBackups = await fs.readdir(repoHistoryDir);
|
||||
|
||||
if (existingBackups.length >= maxBackupCount) {
|
||||
existingBackups.sort();
|
||||
const backupsToDelete = existingBackups.slice(0, existingBackups.length - maxBackupCount + 1);
|
||||
for (const backupToDelete of backupsToDelete) {
|
||||
const backupFilePathToDelete = path.join(repoHistoryDir, backupToDelete);
|
||||
await fs.unlink(backupFilePathToDelete);
|
||||
}
|
||||
}
|
||||
|
||||
const backupFileName = `${backupDate}.log`;
|
||||
const backupFilePath = path.join(repoHistoryDir, backupFileName);
|
||||
const jsonData = JSON.stringify(data, null, 2);
|
||||
|
||||
const logData = `\n>>>> History of file repo.json at "${timestamp}" <<<<\n${jsonData}\n`;
|
||||
|
||||
// Écrire ou réécrire le fichier avec le contenu mis à jour
|
||||
await fs.appendFile(backupFilePath, logData);
|
||||
} catch (error) {
|
||||
console.error('An error occurred while saving the repo history :', error.message);
|
||||
}
|
||||
}
|
||||
52
helpers/functions/repositoryNameCheck.test.ts
Normal file
52
helpers/functions/repositoryNameCheck.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import repositoryNameCheck from './repositoryNameCheck';
|
||||
|
||||
describe('repositoryNameCheck', () => {
|
||||
it('should return true for a valid 8-character hexadecimal string', () => {
|
||||
expect(repositoryNameCheck('a1b2c3d4')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a string shorter than 8 characters', () => {
|
||||
expect(repositoryNameCheck('a1b2c3')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a string longer than 8 characters', () => {
|
||||
expect(repositoryNameCheck('a1b2c3d4e5')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a string with non-hexadecimal characters', () => {
|
||||
expect(repositoryNameCheck('a1b2c3g4')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for an empty string', () => {
|
||||
expect(repositoryNameCheck('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a string with special characters', () => {
|
||||
expect(repositoryNameCheck('a1b2c3d@')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a string with uppercase hexadecimal characters', () => {
|
||||
expect(repositoryNameCheck('A1B2C3D4')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a string with spaces', () => {
|
||||
expect(repositoryNameCheck('a1b2 c3d4')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a non string name', () => {
|
||||
expect(repositoryNameCheck(12345678)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(repositoryNameCheck(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(repositoryNameCheck(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for boolean', () => {
|
||||
expect(repositoryNameCheck(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
9
helpers/functions/repositoryNameCheck.ts
Normal file
9
helpers/functions/repositoryNameCheck.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// BorgWarehouse repository name is an 8-character hexadecimal string
|
||||
|
||||
export default function repositoryNameCheck(name: unknown): boolean {
|
||||
if (typeof name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const repositoryNameRegex = /^[a-f0-9]{8}$/;
|
||||
return repositoryNameRegex.test(name) ? true : false;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
// This function is used to parse the date and time into a human readable format from the timestamp.
|
||||
export default function timestampConverter(UNIX_timestamp) {
|
||||
const a = new Date(UNIX_timestamp * 1000);
|
||||
const year = a.getFullYear();
|
||||
const month = a.getMonth() + 1;
|
||||
const date = a.getDate();
|
||||
const hour = a.getHours();
|
||||
const min = (a.getMinutes() < 10 ? '0' : '') + a.getMinutes();
|
||||
//const sec = a.getSeconds();
|
||||
const time = year + '/' + month + '/' + date + ' ' + hour + ':' + min;
|
||||
return time;
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export default async function tokenController(API_KEY, FROM_IP) {
|
||||
const jsonDirectory = path.join(process.cwd(), 'config');
|
||||
const timestamp = new Date().toISOString();
|
||||
try {
|
||||
if (process.env.DISABLE_INTEGRATIONS === 'true') {
|
||||
console.log(`API auth failed from : ${FROM_IP} [${timestamp}]`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const usersList = await fs.readFile(jsonDirectory + '/users.json', 'utf8');
|
||||
const users = JSON.parse(usersList);
|
||||
const user = users.find(
|
||||
(user) => Array.isArray(user.tokens) && user.tokens.some((token) => token.token === API_KEY)
|
||||
);
|
||||
if (user) {
|
||||
const token = user.tokens.find((token) => token.token === API_KEY);
|
||||
|
||||
if (token && token.permissions && typeof token.permissions === 'object') {
|
||||
console.log(
|
||||
`API auth success with the token '${token.name}' of user '${user.username}' from : ${FROM_IP} [${timestamp}]`
|
||||
);
|
||||
return token.permissions;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`API auth failed from : ${FROM_IP} [${timestamp}]`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
throw new Error('Error with tokenController');
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ authorized_keys="${home}/.ssh/authorized_keys"
|
|||
|
||||
# Check args
|
||||
if [ "$1" == "" ] || [ "$2" == "" ] || ! [[ "$2" =~ ^[0-9]+$ ]] || [ "$3" != "true" ] && [ "$3" != "false" ]; then
|
||||
echo -n "This shell takes 3 arguments : SSH Public Key, Quota in Go [e.g. : 10], Append only mode [true|false]"
|
||||
echo -n "This shell takes 3 arguments : SSH Public Key, Quota in Go [e.g. : 10], Append only mode [true|false]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -41,25 +41,25 @@ fi
|
|||
pattern='(ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3}(\s.*)?'
|
||||
if [[ ! "$1" =~ $pattern ]]
|
||||
then
|
||||
echo -n "Invalid public SSH KEY format. Provide a key in OpenSSH format (rsa, ed25519, ed25519-sk)"
|
||||
echo -n "Invalid public SSH KEY format. Provide a key in OpenSSH format (rsa, ed25519, ed25519-sk)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
## Check if authorized_keys exists
|
||||
if [ ! -f "${authorized_keys}" ];then
|
||||
echo -n "${authorized_keys} must be present"
|
||||
echo -n "${authorized_keys} must be present" >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
# Check if SSH pub key is already present in authorized_keys
|
||||
if grep -q "$1" "$authorized_keys"; then
|
||||
echo -n "SSH pub key already present in authorized_keys"
|
||||
echo -n "SSH pub key already present in authorized_keys" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Check if borgbackup is installed
|
||||
if ! [ -x "$(command -v borg)" ]; then
|
||||
echo -n "You must install borgbackup package."
|
||||
echo -n "You must install borgbackup package." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ else
|
|||
fi
|
||||
|
||||
## Add ssh public key in authorized_keys with borg restriction for only 1 repository and storage quota
|
||||
restricted_authkeys="command=\"cd ${pool};borg serve${appendOnlyMode} --restrict-to-path ${pool}/${repositoryName} --storage-quota $2G\",restrict $1"
|
||||
restricted_authkeys="command=\"cd ${pool};borg serve${appendOnlyMode} --restrict-to-repository ${pool}/${repositoryName} --storage-quota $2G\",restrict $1"
|
||||
echo "$restricted_authkeys" | tee -a "${authorized_keys}" >/dev/null
|
||||
|
||||
## Return the repositoryName
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
### DEPRECATED ### NodeJS will handle this in the future.
|
||||
|
||||
# Shell created by Raven for BorgWarehouse.
|
||||
# This shell takes 1 arg : [repositoryName] with 8 char. length only.
|
||||
# This shell **delete the repository** in arg and **all his data** and the line associated in the authorized_keys file.
|
||||
|
|
@ -21,7 +23,7 @@ authorized_keys="${home}/.ssh/authorized_keys"
|
|||
|
||||
# Check arg
|
||||
if [[ $# -ne 1 || $1 = "" ]]; then
|
||||
echo -n "You must provide a repositoryName in argument."
|
||||
echo -n "You must provide a repositoryName in argument." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -29,7 +31,7 @@ fi
|
|||
# If we receive another pattern there is necessarily a problem.
|
||||
repositoryName=$1
|
||||
if ! [[ "$repositoryName" =~ ^[a-f0-9]{8}$ ]]; then
|
||||
echo "Invalid repository name. Must be an 8-character hex string."
|
||||
echo "Invalid repository name. Must be an 8-character hex string." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
### DEPRECATED ### NodeJS will handle this in the future.
|
||||
|
||||
# Shell created by Raven for BorgWarehouse.
|
||||
# Get the timestamp of the last modification of the file integrity.* for of all repositories in a JSON output.
|
||||
# stdout will be an array like :
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
### DEPRECATED ### NodeJS will handle this in the future.
|
||||
|
||||
# Shell created by Raven for BorgWarehouse.
|
||||
# Get the size of all repositories in a JSON output.
|
||||
# stdout will be an array like :
|
||||
|
|
@ -27,7 +29,7 @@ fi
|
|||
|
||||
# Get the size of each repository and format as JSON
|
||||
cd "${home}"/repos
|
||||
output=$(du -s -- * 2>/dev/null | awk '{print "{\"size\":" $1 ",\"name\":\"" $2 "\"}"}' | jq -s '.')
|
||||
output=$(du -s -L -- * 2>/dev/null | awk '{print "{\"size\":" $1 ",\"name\":\"" $2 "\"}"}' | jq -s '.')
|
||||
if [ -z "$output" ]; then
|
||||
output="[]"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
### DEPRECATED ### NodeJS will handle this in the future.
|
||||
|
||||
# Shell created by Raven for BorgWarehouse.
|
||||
# This shell takes 4 args: [repositoryName] [new SSH pub key] [quota] [append-only mode (boolean)]
|
||||
# This shell updates the SSH key and the quota for a repository.
|
||||
|
|
@ -17,7 +19,7 @@ fi
|
|||
|
||||
# Check args
|
||||
if [ "$1" == "" ] || [ "$2" == "" ] || [ "$3" == "" ] || [ "$4" != "true" ] && [ "$4" != "false" ]; then
|
||||
echo -n "This shell takes 4 args: [repositoryName] [new SSH pub key] [quota] [Append only mode [true|false]]"
|
||||
echo -n "This shell takes 4 args: [repositoryName] [new SSH pub key] [quota] [Append only mode [true|false]]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -26,7 +28,7 @@ fi
|
|||
pattern='(ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3}(\s.*)?'
|
||||
if [[ ! "$2" =~ $pattern ]]
|
||||
then
|
||||
echo -n "Invalid public SSH KEY format. Provide a key in OpenSSH format (rsa, ed25519, ed25519-sk)"
|
||||
echo -n "Invalid public SSH KEY format. Provide a key in OpenSSH format (rsa, ed25519, ed25519-sk)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
|
@ -34,13 +36,13 @@ fi
|
|||
# If we receive another pattern there is necessarily a problem.
|
||||
repositoryName=$1
|
||||
if ! [[ "$repositoryName" =~ ^[a-f0-9]{8}$ ]]; then
|
||||
echo "Invalid repository name. Must be an 8-character hex string."
|
||||
echo "Invalid repository name. Must be an 8-character hex string." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Check if a line in authorized_keys contains repository_name
|
||||
if ! grep -q "command=\".*${repositoryName}.*\",restrict" "$home/.ssh/authorized_keys"; then
|
||||
echo -n "No line containing $repositoryName found in authorized_keys"
|
||||
echo -n "No line containing $repositoryName found in authorized_keys" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
|
|
@ -64,7 +66,7 @@ while IFS= read -r line; do
|
|||
fi
|
||||
done < "$home/.ssh/authorized_keys"
|
||||
if [ "$found" = true ]; then
|
||||
echo -n "This SSH pub key is already present in authorized_keys on a different line."
|
||||
echo -n "This SSH pub key is already present in authorized_keys on a different line." >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export default function emailTest(mailTo, username, aliasList) {
|
||||
const aliasTemplate = (x) => {
|
||||
import path from 'path';
|
||||
|
||||
export default function emailTest(mailTo: string, username: string, aliasList: string[]) {
|
||||
const aliasTemplate = (x: string[]) => {
|
||||
let str = '';
|
||||
for (const alias of x) {
|
||||
str = str + '<li>' + alias + '</li>';
|
||||
|
|
@ -126,7 +128,7 @@ export default function emailTest(mailTo, username, aliasList) {
|
|||
`,
|
||||
attachments: [
|
||||
{
|
||||
path: 'helpers/templates/attachments/alert-icon.png',
|
||||
path: path.join(process.cwd(), 'helpers/templates/attachments/alert-icon.png'),
|
||||
cid: 'alert-icon',
|
||||
},
|
||||
],
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export default function emailTest(mailTo, username) {
|
||||
import path from 'path';
|
||||
|
||||
export default function emailTest(mailTo: string, username: string) {
|
||||
const template = {
|
||||
from: 'BorgWarehouse' + '<' + process.env.MAIL_SMTP_FROM + '>',
|
||||
to: mailTo,
|
||||
|
|
@ -94,7 +96,7 @@ export default function emailTest(mailTo, username) {
|
|||
`,
|
||||
attachments: [
|
||||
{
|
||||
path: 'helpers/templates/attachments/valid-icon.png',
|
||||
path: path.join(process.cwd(), 'helpers/templates/attachments/valid-icon.png'),
|
||||
cid: 'valid-icon',
|
||||
},
|
||||
],
|
||||
1
hooks/index.ts
Normal file
1
hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './useFormStatus';
|
||||
32
hooks/useFormStatus.ts
Normal file
32
hooks/useFormStatus.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useState } from 'react';
|
||||
import { Optional } from '~/types';
|
||||
|
||||
export function useFormStatus() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [error, setError] = useState<Optional<string>>(undefined);
|
||||
|
||||
const handleSuccess = () => {
|
||||
setIsLoading(false);
|
||||
setIsSaved(true);
|
||||
setTimeout(() => setIsSaved(false), 3000);
|
||||
};
|
||||
|
||||
const handleError = (message: string) => {
|
||||
setIsLoading(false);
|
||||
setError(message);
|
||||
setTimeout(() => setError(undefined), 4000);
|
||||
};
|
||||
|
||||
const clearError = () => setError(undefined);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isSaved,
|
||||
error,
|
||||
setIsLoading,
|
||||
handleSuccess,
|
||||
handleError,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
BIN
medias/borgwarehouse-og.jpg
Normal file
BIN
medias/borgwarehouse-og.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 557 KiB |
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
module.exports = {
|
||||
// nextConfig
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
|
@ -21,3 +20,5 @@ module.exports = {
|
|||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6660
package-lock.json
generated
6660
package-lock.json
generated
File diff suppressed because it is too large
Load diff
86
package.json
86
package.json
|
|
@ -1,38 +1,52 @@
|
|||
{
|
||||
"name": "borgwarehouse",
|
||||
"version": "2.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"setup": "npm install && npm run setup:hooks",
|
||||
"setup:hooks": "npx husky install",
|
||||
"format": "prettier --write \"{Components,Containers,helpers,pages,styles}/**/*.{js,jsx,ts,tsx,json,css,scss,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.24.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chart.js": "^4.4.7",
|
||||
"next": "^15.0.4",
|
||||
"next-auth": "^4.24.10",
|
||||
"nodemailer": "^6.9.16",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-select": "^5.8.3",
|
||||
"react-toastify": "^10.0.6",
|
||||
"spinners-react": "^1.0.10",
|
||||
"swr": "^2.2.5",
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.6.0",
|
||||
"@commitlint/config-conventional": "^19.6.0",
|
||||
"eslint-config-next": "^15.0.4",
|
||||
"husky": "^9.1.7",
|
||||
"prettier": "^3.4.2"
|
||||
}
|
||||
"name": "borgwarehouse",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "pnpm exec eslint",
|
||||
"test": "vitest",
|
||||
"setup": "pnpm install && pnpm run setup:hooks",
|
||||
"setup:hooks": "pnpm exec husky install",
|
||||
"format": "prettier --write \"{Components,Containers,helpers,pages,styles}/**/*.{js,jsx,ts,tsx,json,css,scss,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.37.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lowdb": "^7.0.1",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"nodemailer": "^8.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-select": "^5.10.2",
|
||||
"react-toastify": "^11.0.5",
|
||||
"swr": "^2.4.1",
|
||||
"use-media": "^1.5.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.2",
|
||||
"@commitlint/config-conventional": "^20.4.2",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
"node-mocks-http": "^1.17.2",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
//Lib
|
||||
import Head from 'next/head';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function Error404() {
|
||||
//Var
|
||||
const { status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
//Lib
|
||||
import '../styles/default.css';
|
||||
import Head from 'next/head';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
//Components
|
||||
import Layout from '../Components/UI/Layout/Layout';
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
return (
|
||||
<SessionProvider session={pageProps.session}>
|
||||
<Layout>
|
||||
<Head>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'></meta>
|
||||
<link rel='shortcut icon' href='/favicon.ico' />
|
||||
<title>BorgWarehouse</title>
|
||||
</Head>
|
||||
<ToastContainer stacked />
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue