mirror of
https://github.com/Ravinou/borgwarehouse
synced 2026-03-15 14:55:45 +01:00
Compare commits
No commits in common. "main" and "v2.4.0" have entirely different histories.
203 changed files with 11025 additions and 14719 deletions
|
|
@ -1,4 +1,4 @@
|
|||
const config = {
|
||||
export default {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
|
|
@ -19,12 +19,8 @@ const config = {
|
|||
'ui',
|
||||
'wip',
|
||||
'publish',
|
||||
'docker',
|
||||
'WIP',
|
||||
],
|
||||
],
|
||||
},
|
||||
ignores: [(message) => message.includes('WIP'), (message) => message.includes('wip')],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ 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
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -1,35 +0,0 @@
|
|||
---
|
||||
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
21
.github/ISSUE_TEMPLATE/i-need-help.md
vendored
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
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,18 +1,16 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/'
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
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: '/'
|
||||
interval: "daily"
|
||||
- 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"
|
||||
|
|
|
|||
29
.github/workflows/bats.yml
vendored
29
.github/workflows/bats.yml
vendored
|
|
@ -1,29 +0,0 @@
|
|||
name: Bats
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
jobs:
|
||||
bats-test:
|
||||
name: Run bats tests against shells
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build container & run bats tests
|
||||
run: |
|
||||
docker compose -f tests/bats/docker-compose.yml up --abort-on-container-exit --build
|
||||
57
.github/workflows/docker-image-develop.yml
vendored
57
.github/workflows/docker-image-develop.yml
vendored
|
|
@ -1,38 +1,29 @@
|
|||
name: Build and Push Docker Image for Develop Branch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
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
|
||||
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
|
||||
|
|
|
|||
4
.github/workflows/docker-image-latest.yml
vendored
4
.github/workflows/docker-image-latest.yml
vendored
|
|
@ -1,6 +1,4 @@
|
|||
name: Build and Push Docker Image
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -12,7 +10,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- 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,15 +5,12 @@ on:
|
|||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
|
|
|||
37
.github/workflows/docker-image-test.yml
vendored
37
.github/workflows/docker-image-test.yml
vendored
|
|
@ -1,24 +1,21 @@
|
|||
name: Test to build docker container on Pull Request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
name: Test Docker Container Build on Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build-container:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build BorgWarehouse Container
|
||||
run: |
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t borgwarehouse:pr-${{ github.event.pull_request.number }} .
|
||||
build-container:
|
||||
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: Build Docker Container
|
||||
run: |
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t borgwarehouse:pr-${{ github.event.pull_request.number }} .
|
||||
|
|
|
|||
12
.github/workflows/shellcheck.yml
vendored
12
.github/workflows/shellcheck.yml
vendored
|
|
@ -4,21 +4,19 @@ on:
|
|||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
branches: main
|
||||
|
||||
name: 'Shellcheck'
|
||||
name: "Shellcheck"
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
shellcheck:
|
||||
name: Shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@master
|
||||
env:
|
||||
|
|
|
|||
63
.github/workflows/vitest.yml
vendored
63
.github/workflows/vitest.yml
vendored
|
|
@ -1,63 +0,0 @@
|
|||
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
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -50,14 +50,6 @@ 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
|
||||
|
||||
|
|
@ -119,7 +111,4 @@ config/repo.json
|
|||
config/users.json
|
||||
|
||||
# docker files
|
||||
docker-compose.yml
|
||||
|
||||
# Commit tests docker-compose
|
||||
!tests/bats/docker-compose.yml
|
||||
docker-compose.yml
|
||||
|
|
@ -23,43 +23,40 @@ function checkBreakingChangeInBody() {
|
|||
}
|
||||
|
||||
function findTypeIcon() {
|
||||
# get message from 1st param
|
||||
message="$1"
|
||||
|
||||
if [[ "$message" =~ ^.*!:\ .* ]]; then
|
||||
echo "$boomIcon"
|
||||
return 0
|
||||
fi
|
||||
# declare an icons for each authorized enum-type from `.commitlintrc.js`
|
||||
declare -A icons
|
||||
icons[build]='🤖'
|
||||
icons[chore]='🧹'
|
||||
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]='🚧'
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
# extract original message from the first line of file
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
#!/bin/bash
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# run commit lint
|
||||
npx commitlint --edit "$1"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
#!/bin/bash
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Check if it's an amend commit
|
||||
if [ "$2" = "commit" ]; then
|
||||
echo "Amendment detected, appending icon..."
|
||||
|
|
|
|||
7
.npmrc
7
.npmrc
|
|
@ -1,7 +0,0 @@
|
|||
# Configuration pnpm
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
shamefully-hoist=false
|
||||
|
||||
# Force pnpm usage (prevent npm/yarn)
|
||||
package-manager=pnpm
|
||||
|
|
@ -1,30 +1,26 @@
|
|||
//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';
|
||||
import { WizardEnvType } from '~/types/domain/config.types';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
|
||||
type QuickCommandsProps = {
|
||||
repositoryName: string;
|
||||
wizardEnv?: WizardEnvType;
|
||||
lanCommand?: boolean;
|
||||
};
|
||||
|
||||
export default function QuickCommands(props: QuickCommandsProps) {
|
||||
export default function QuickCommands(props) {
|
||||
////Vars
|
||||
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 ? SSH_SERVER_PORT : ''}/./${props.repositoryName}`
|
||||
)
|
||||
.writeText(`ssh://${wizardEnv.UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.repositoryName}`)
|
||||
.then(() => {
|
||||
// If successful, update the isCopied state value
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
|
|
@ -41,8 +37,8 @@ export default function QuickCommands(props: QuickCommandsProps) {
|
|||
<div className={classes.copyValid}>Copied !</div>
|
||||
) : (
|
||||
<div className={classes.tooltip}>
|
||||
ssh://{wizardEnv?.UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT ? SSH_SERVER_PORT : ''}/./
|
||||
ssh://{wizardEnv.UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.repositoryName}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
margin: auto 25px auto auto;
|
||||
margin: auto 47px auto auto;
|
||||
}
|
||||
|
||||
.icons {
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #6d4aff21;
|
||||
background-color: #fafafa;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset;
|
||||
color: #65748b;
|
||||
|
|
@ -50,7 +50,6 @@
|
|||
|
||||
.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;
|
||||
|
|
@ -77,7 +76,7 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #6d4aff21;
|
||||
background-color: #fafafa;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset;
|
||||
color: #65748b;
|
||||
|
|
|
|||
180
Components/Repo/Repo.js
Normal file
180
Components/Repo/Repo.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
//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,39 +13,16 @@
|
|||
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 {
|
||||
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;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
/* REPO OPEN */
|
||||
|
|
@ -58,6 +35,7 @@
|
|||
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;
|
||||
|
|
@ -65,28 +43,14 @@
|
|||
overflow: visible;
|
||||
/* Need to display comment on hover (which is position : absolute) */
|
||||
position: relative;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.openFlex {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aliasFlex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.indicatorsFlex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.tabInfo {
|
||||
width: 100%;
|
||||
|
|
@ -95,7 +59,7 @@
|
|||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 15px auto;
|
||||
margin: 25px auto;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +73,7 @@
|
|||
font-size: 1em;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.tabInfo tbody tr {
|
||||
|
|
@ -124,52 +88,80 @@
|
|||
}
|
||||
|
||||
/*STATUS*/
|
||||
.statusIndicatorGreen,
|
||||
.statusIndicatorRed {
|
||||
border-radius: 50%;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
animation: pulse 5s infinite;
|
||||
}
|
||||
|
||||
.statusIndicatorGreen {
|
||||
background: #00d26a;
|
||||
box-shadow: 0 0 0 0 rgba(0, 210, 106, 0.7);
|
||||
background: rgb(9, 255, 0);
|
||||
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;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
.statusIndicatorRed {
|
||||
background: #ff3d3d;
|
||||
box-shadow: 0 0 0 0 rgba(255, 61, 61, 0.7);
|
||||
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;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@keyframes pulseRed {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
|
||||
box-shadow: 0 0 0 10px rgba(255, 0, 0, 0);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
box-shadow: 0 0 0 0 rgba(255, 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 */
|
||||
|
|
@ -177,13 +169,6 @@
|
|||
font-weight: bold;
|
||||
color: #111827;
|
||||
font-size: 1.05em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.RepoOpen .alias {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.lastSave {
|
||||
|
|
@ -199,6 +184,7 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.toolTip {
|
||||
|
|
@ -241,69 +227,23 @@
|
|||
|
||||
/* MOBILE */
|
||||
@media all and (max-width: 1000px) {
|
||||
.openFlex,
|
||||
.tabInfo,
|
||||
.toolTip,
|
||||
.comment,
|
||||
.chevron {
|
||||
display: none !important;
|
||||
.tabInfo {
|
||||
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;
|
||||
.toolTip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.closeFlex {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
.comment {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appendOnlyModeIcon,
|
||||
.alertIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.closeFlex {
|
||||
margin: auto;
|
||||
}
|
||||
.openFlex {
|
||||
margin: auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,261 +0,0 @@
|
|||
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,19 +1,14 @@
|
|||
//Lib
|
||||
import classes from './CopyButton.module.css';
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { IconChecks, IconCopy } from '@tabler/icons-react';
|
||||
|
||||
type CopyButtonProps = {
|
||||
dataToCopy: string;
|
||||
children?: ReactNode;
|
||||
displayIconConfirmation?: boolean;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
};
|
||||
|
||||
export default function CopyButton(props: CopyButtonProps) {
|
||||
export default function CopyButton(props) {
|
||||
//State
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = async (data: string) => {
|
||||
//Function
|
||||
const handleCopy = async (data) => {
|
||||
navigator.clipboard
|
||||
.writeText(data)
|
||||
.then(() => {
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
//Lib
|
||||
import classes from './Error.module.css';
|
||||
|
||||
type ErrorProps = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export default function Error(props: ErrorProps) {
|
||||
export default function Error(props) {
|
||||
return <div className={classes.errorMessage}>{props.message}</div>;
|
||||
}
|
||||
|
|
@ -1,13 +1,7 @@
|
|||
import { ReactNode } from 'react';
|
||||
//Lib
|
||||
import classes from './Info.module.css';
|
||||
|
||||
type InfoProps = {
|
||||
message: string;
|
||||
color?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Info(props: InfoProps) {
|
||||
export default function Info(props) {
|
||||
return (
|
||||
<div className={classes.infoMessage} style={{ backgroundColor: props.color }}>
|
||||
{props.message}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
//Lib
|
||||
import classes from './Footer.module.css';
|
||||
import packageInfo from '~/package.json';
|
||||
import packageInfo from '../../../../package.json';
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
|
|
@ -1,21 +1,14 @@
|
|||
import Image from 'next/image';
|
||||
//Lib
|
||||
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}>
|
||||
<Image
|
||||
src='/borgwarehouse-logo-violet.svg'
|
||||
alt='BorgWarehouse'
|
||||
width={225}
|
||||
height={40}
|
||||
className={classes.logoImage}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.logo}>BorgWarehouse</div>
|
||||
|
||||
<nav>
|
||||
<Nav />
|
||||
|
|
@ -27,5 +27,5 @@
|
|||
font-weight: bold;
|
||||
color: #6d4aff;
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
margin-left: 70px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//Lib
|
||||
import classes from './Nav.module.css';
|
||||
import { IconUser, IconLogout } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
|
|
@ -5,10 +6,13 @@ 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.
|
||||
|
|
@ -21,12 +25,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 : undefined}>
|
||||
<Link href='/account' className={currentRoute === '/account' ? classes.active : null}>
|
||||
<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,14 +1,12 @@
|
|||
//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';
|
||||
|
||||
type LayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Layout(props: LayoutProps) {
|
||||
function Layout(props) {
|
||||
//Var
|
||||
const { status } = useSession();
|
||||
|
||||
if (status === 'authenticated') {
|
||||
|
|
@ -1,16 +1,21 @@
|
|||
//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 : undefined}>
|
||||
<Link href='/' className={currentRoute === '/' ? classes.active : null}>
|
||||
<IconServer size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Repositories</span>
|
||||
|
|
@ -18,17 +23,14 @@ export default function NavSide() {
|
|||
<li className={classes.NavSideItem}>
|
||||
<Link
|
||||
href='/setup-wizard/1'
|
||||
className={currentRoute === '/setup-wizard/[slug]' ? classes.active : undefined}
|
||||
className={currentRoute === '/setup-wizard/[slug]' ? classes.active : null}
|
||||
>
|
||||
<IconSettingsAutomation size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Setup Wizard</span>
|
||||
</li>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link
|
||||
href='/monitoring'
|
||||
className={currentRoute === '/monitoring' ? classes.active : undefined}
|
||||
>
|
||||
<Link href='/monitoring' className={currentRoute === '/monitoring' ? classes.active : null}>
|
||||
<IconActivityHeartbeat size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Monitoring</span>
|
||||
|
|
@ -1,11 +1,6 @@
|
|||
//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}>
|
||||
|
|
@ -13,9 +8,11 @@ export default function ShimmerRepoList() {
|
|||
<div className={classes.buttonIsLoading} />
|
||||
</div>
|
||||
<div className={classes.loadingRepoContainer}>
|
||||
{Array.from({ length: LOADING_REPO_COUNT }, (_, i) => (
|
||||
<ShimmerRepoItem key={i} />
|
||||
))}
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,15 +1,10 @@
|
|||
//Lib
|
||||
import classes from './StorageBar.module.css';
|
||||
|
||||
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
|
||||
);
|
||||
export default function StorageBar(props) {
|
||||
//Var
|
||||
//storageUsed is in octet, storageSize is in GB. Round to 1 decimal for %.
|
||||
const storageUsedPercent = (((props.storageUsed / 1000000) * 100) / props.storageSize).toFixed(1);
|
||||
|
||||
return (
|
||||
<div className={classes.barContainer}>
|
||||
|
|
@ -24,8 +19,8 @@ export default function StorageBar(props: StorageBarProps) {
|
|||
<div className={classes.progressionStyle} />
|
||||
</div>
|
||||
<div className={classes.tooltip}>
|
||||
{storageUsedPercent}% ({(props.storageUsed / 1024 ** 2).toFixed(1)} GB /{' '}
|
||||
{props.storageSize} GB)
|
||||
{storageUsedPercent}% ({(props.storageUsed / 1000000).toFixed(1)} GB / {props.storageSize}{' '}
|
||||
GB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
26
Components/UI/Switch/Switch.js
Normal file
26
Components/UI/Switch/Switch.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//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,84 +1,157 @@
|
|||
/* Wrapper styles */
|
||||
.switchWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Switch container */
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.switchLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
.switchDescription {
|
||||
display: flex;
|
||||
margin: 8px 0px 0px 0px;
|
||||
color: #6c737f;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch {
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.switchLabel input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.switchSlider {
|
||||
position: relative;
|
||||
.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%;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background: #ccc;
|
||||
border-radius: 12px;
|
||||
transition: #ccc 0.3s ease;
|
||||
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;
|
||||
}
|
||||
|
||||
.switchSlider::after {
|
||||
/* 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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
right: 16px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
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;
|
||||
}
|
||||
|
||||
/* Checked styles */
|
||||
.switchLabel input:checked + .switchSlider {
|
||||
background: #704dff;
|
||||
/* Checked */
|
||||
.pureMaterialSwitch > input:checked {
|
||||
right: -10px;
|
||||
background-color: rgb(var(--pure-material-primary-rgb, 109, 74, 255));
|
||||
}
|
||||
|
||||
.switchLabel input:checked + .switchSlider::after {
|
||||
transform: translateX(20px);
|
||||
.pureMaterialSwitch > input:checked + span::before {
|
||||
background-color: rgba(var(--pure-material-primary-rgb, 109, 74, 255), 0.6);
|
||||
}
|
||||
|
||||
/* Disabled styles */
|
||||
.switchLabel input:disabled + .switchSlider {
|
||||
background: #e0e0e0;
|
||||
.pureMaterialSwitch > input:checked + span::after {
|
||||
background-color: rgb(var(--pure-material-primary-rgb, 109, 74, 255));
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.switchLabel input:disabled + .switchSlider::after {
|
||||
background: #bdbdbd;
|
||||
/* Active */
|
||||
.pureMaterialSwitch > input:active {
|
||||
opacity: 1;
|
||||
transform: scale(0);
|
||||
transition:
|
||||
transform 0s,
|
||||
opacity 0s;
|
||||
}
|
||||
|
||||
/* Switch text */
|
||||
.switchText {
|
||||
font-size: 1rem;
|
||||
color: #494b7a;
|
||||
font-weight: 500;
|
||||
.pureMaterialSwitch > input:active + span::before {
|
||||
background-color: rgba(var(--pure-material-primary-rgb, 109, 74, 255), 0.6);
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.switchDescription {
|
||||
font-size: 0.875rem;
|
||||
color: #6c737f;
|
||||
margin-top: 4px;
|
||||
.pureMaterialSwitch > input:checked:active + span::before {
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
} */
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
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,3 +1,4 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconDeviceDesktopAnalytics, IconTerminal2 } from '@tabler/icons-react';
|
||||
|
|
@ -48,7 +49,15 @@ 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,14 +1,16 @@
|
|||
import { IconAlertCircle, IconTool } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import { WizardStepProps } from '~/types';
|
||||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
import { IconTool, IconAlertCircle } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
|
||||
function WizardStep2(props: WizardStepProps) {
|
||||
function WizardStep2(props) {
|
||||
////Vars
|
||||
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.selectedRepo?.lanCommand);
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedOption.lanCommand);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
|
|
@ -30,10 +32,10 @@ function WizardStep2(props: WizardStepProps) {
|
|||
borg init -e repokey-blake2 ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
{props.selectedOption.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg init -e repokey-blake2 ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
dataToCopy={`borg init -e repokey-blake2 ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.note}>
|
||||
|
|
@ -69,8 +71,8 @@ function WizardStep2(props: WizardStepProps) {
|
|||
|
||||
<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={{
|
||||
|
|
@ -83,10 +85,10 @@ function WizardStep2(props: WizardStepProps) {
|
|||
ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
{props.selectedOption.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
dataToCopy={`ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -98,21 +100,19 @@ function WizardStep2(props: WizardStepProps) {
|
|||
<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,15 +1,16 @@
|
|||
//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 { WizardStepProps } from '~/types';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
|
||||
function WizardStep3(props: WizardStepProps) {
|
||||
function WizardStep3(props) {
|
||||
////Vars
|
||||
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.selectedRepo?.lanCommand);
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedOption.lanCommand);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
|
|
@ -30,11 +31,11 @@ function WizardStep3(props: WizardStepProps) {
|
|||
borg create ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
{props.selectedOption.repositoryName}
|
||||
::archive1 /your/pathToBackup
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg create ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 /your/pathToBackup`}
|
||||
dataToCopy={`borg create ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 /your/pathToBackup`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -69,10 +70,10 @@ function WizardStep3(props: WizardStepProps) {
|
|||
borg check -v --progress ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
{props.selectedOption.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg check -v --progress ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
dataToCopy={`borg check -v --progress ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<li>List the remote archives with :</li>
|
||||
|
|
@ -87,10 +88,10 @@ function WizardStep3(props: WizardStepProps) {
|
|||
borg list ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
{props.selectedOption.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg list ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
dataToCopy={`borg list ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<li>Download a remote archive with the following command :</li>
|
||||
|
|
@ -102,14 +103,14 @@ function WizardStep3(props: WizardStepProps) {
|
|||
}}
|
||||
>
|
||||
<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.selectedRepo?.repositoryName}
|
||||
{props.selectedOption.repositoryName}
|
||||
::archive1 archive1.tar.gz
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg export-tar --tar-filter="gzip -9" ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 archive1.tar.gz`}
|
||||
dataToCopy={`borg export-tar --tar-filter="gzip -9" ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 archive1.tar.gz`}
|
||||
/>
|
||||
</div>
|
||||
<li>Mount an archive to compare or backup some files without download all the archive :</li>
|
||||
|
|
@ -124,11 +125,11 @@ function WizardStep3(props: WizardStepProps) {
|
|||
borg mount ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
{props.selectedOption.repositoryName}
|
||||
::archive1 /tmp/yourMountPoint
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg mount ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 /tmp/yourMountPoint`}
|
||||
dataToCopy={`borg mount ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 /tmp/yourMountPoint`}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
//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 { WizardStepProps } from '~/types';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
|
||||
function WizardStep4(props: WizardStepProps) {
|
||||
function WizardStep4(props) {
|
||||
////Vars
|
||||
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.selectedRepo?.lanCommand);
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedOption.lanCommand);
|
||||
|
||||
const configBorgmatic = `
|
||||
const configBorgmatic = `location:
|
||||
# List of source directories to backup.
|
||||
source_directories:
|
||||
- /your-repo-to-backup
|
||||
|
|
@ -19,21 +20,24 @@ function WizardStep4(props: WizardStepProps) {
|
|||
|
||||
repositories:
|
||||
# Paths of local or remote repositories to backup to.
|
||||
- path: ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}
|
||||
- ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}
|
||||
|
||||
archive_name_format: '{FQDN}-documents-{now}'
|
||||
encryption_passphrase: "YOUR PASSPHRASE"
|
||||
storage:
|
||||
archive_name_format: '{FQDN}-documents-{now}'
|
||||
encryption_passphrase: "YOUR PASSPHRASE"
|
||||
|
||||
# Retention policy for how many backups to keep.
|
||||
keep_daily: 7
|
||||
keep_weekly: 4
|
||||
keep_monthly: 6
|
||||
retention:
|
||||
# Retention policy for how many backups to keep.
|
||||
keep_daily: 7
|
||||
keep_weekly: 4
|
||||
keep_monthly: 6
|
||||
|
||||
# List of checks to run to validate your backups.
|
||||
checks:
|
||||
- name: repository
|
||||
- name: archives
|
||||
frequency: 2 weeks
|
||||
consistency:
|
||||
# List of checks to run to validate your backups.
|
||||
checks:
|
||||
- name: repository
|
||||
- name: archives
|
||||
frequency: 2 weeks
|
||||
|
||||
#hooks:
|
||||
# Custom preparation scripts to run.
|
||||
|
|
@ -1,17 +1,12 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from './WizardStepBar.module.css';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
type WizardStepBarProps = {
|
||||
step: number;
|
||||
setStep: (step: number) => void;
|
||||
previousStepHandler: () => void;
|
||||
nextStepHandler: () => void;
|
||||
};
|
||||
|
||||
function WizardStepBar(props: WizardStepBarProps) {
|
||||
function WizardStepBar(props) {
|
||||
////Functions
|
||||
//Color onClick on a step
|
||||
const colorHandler = (step: number) => {
|
||||
const colorHandler = (step) => {
|
||||
if (step <= props.step) {
|
||||
return classes.active;
|
||||
} else {
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
//Lib
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
|
|
@ -9,15 +10,16 @@ import {
|
|||
} from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Repository, Optional } from '~/types';
|
||||
|
||||
export default function StorageUsedChartBar() {
|
||||
const [data, setData] = useState<Optional<Array<Repository>>>();
|
||||
//States
|
||||
const [data, setData] = useState([]);
|
||||
|
||||
//LifeCycle
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/repositories', {
|
||||
const response = await fetch('/api/repo', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -39,10 +41,10 @@ export default function StorageUsedChartBar() {
|
|||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
position: 'bottom',
|
||||
},
|
||||
title: {
|
||||
position: 'bottom' as const,
|
||||
position: 'bottom',
|
||||
display: true,
|
||||
text: 'Storage used for each repository',
|
||||
},
|
||||
|
|
@ -53,7 +55,7 @@ export default function StorageUsedChartBar() {
|
|||
min: 0,
|
||||
ticks: {
|
||||
// Include a dollar sign in the ticks
|
||||
callback: function (value: number | string) {
|
||||
callback: function (value) {
|
||||
return value + '%';
|
||||
},
|
||||
stepSize: 10,
|
||||
|
|
@ -62,16 +64,16 @@ export default function StorageUsedChartBar() {
|
|||
},
|
||||
};
|
||||
|
||||
const labels = data?.map((repo) => repo.alias);
|
||||
const labels = data.map((repo) => repo.alias);
|
||||
|
||||
const dataChart = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Storage used (%)',
|
||||
//storageUsed is in kB, storageSize is in GB. Round to 1 decimal for %.
|
||||
data: data?.map((repo) =>
|
||||
(((repo.storageUsed / 1024 ** 2) * 100) / repo.storageSize).toFixed(1)
|
||||
//storageUsed is in octet, storageSize is in GB. Round to 1 decimal for %.
|
||||
data: data.map((repo) =>
|
||||
(((repo.storageUsed / 1000000) * 100) / repo.storageSize).toFixed(1)
|
||||
),
|
||||
backgroundColor: '#704dff',
|
||||
},
|
||||
159
Containers/RepoList/RepoList.js
Normal file
159
Containers/RepoList/RepoList.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//Lib
|
||||
import classes from './RepoList.module.css';
|
||||
import { 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 (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
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,6 +86,7 @@
|
|||
flex-direction: column;
|
||||
width: 90%;
|
||||
margin: 5px auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.unfoldButton {
|
||||
|
|
@ -122,77 +123,3 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,279 +0,0 @@
|
|||
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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +1,29 @@
|
|||
import { IconAlertCircle, IconExternalLink, IconX } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
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';
|
||||
//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 Link from 'next/link';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import { alertOptions } from '../../domain/constants';
|
||||
|
||||
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) {
|
||||
export default function RepoManage(props) {
|
||||
////Var
|
||||
let targetRepo;
|
||||
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<DataForm>({ mode: 'onChange' });
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
const toastOptions: ToastOptions = {
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -50,113 +33,108 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
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 (props.mode === 'edit') {
|
||||
if (!router.query.slug) {
|
||||
start();
|
||||
return;
|
||||
} else if (!targetRepo) {
|
||||
stop();
|
||||
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) {
|
||||
router.push('/404');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
//Delete a repo
|
||||
const deleteHandler = async (repositoryName?: string) => {
|
||||
start();
|
||||
if (!repositoryName) {
|
||||
stop();
|
||||
toast.error('Repository name not found', toastOptions);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
const deleteHandler = async () => {
|
||||
//API Call for delete
|
||||
await fetch('/api/v1/repositories/' + repositoryName, {
|
||||
fetch('/api/repo/id/' + router.query.slug + '/delete', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'🗑 The repository ' + repositoryName + ' has been successfully deleted',
|
||||
'🗑 The repository #' + router.query.slug + ' 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
|
||||
);
|
||||
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');
|
||||
}
|
||||
else toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log('Fail to delete');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
});
|
||||
};
|
||||
|
||||
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(' ');
|
||||
//Verify that the SSH key is unique
|
||||
const isSSHKeyUnique = async (sshPublicKey) => {
|
||||
let isUnique = true;
|
||||
|
||||
const response = await fetch('/api/v1/repositories', { method: 'GET' });
|
||||
const data: { repoList: Repository[] } = await response.json();
|
||||
// Extract the first two columns of the SSH key in the form
|
||||
const publicKeyPrefix = sshPublicKey.split(' ').slice(0, 2).join(' ');
|
||||
|
||||
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)
|
||||
);
|
||||
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;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
return isUnique;
|
||||
};
|
||||
|
||||
//Form submit Handler for ADD or EDIT a repo
|
||||
const formSubmitHandler = async (dataForm: DataForm) => {
|
||||
const formSubmitHandler = async (dataForm) => {
|
||||
//Loading button on submit to avoid multiple send.
|
||||
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(cleanedSSHKey))) {
|
||||
stop();
|
||||
if (!(await isSSHKeyUnique(dataForm.sshkey))) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -165,14 +143,14 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
const newRepo = {
|
||||
alias: dataForm.alias,
|
||||
storageSize: parseInt(dataForm.storageSize),
|
||||
sshPublicKey: cleanedSSHKey,
|
||||
sshPublicKey: dataForm.sshkey,
|
||||
comment: dataForm.comment,
|
||||
alert: dataForm.alert.value,
|
||||
lanCommand: dataForm.lanCommand,
|
||||
appendOnlyMode: dataForm.appendOnlyMode,
|
||||
};
|
||||
//POST API to send new repo
|
||||
await fetch('/api/v1/repositories', {
|
||||
await fetch('/api/repo/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -185,7 +163,7 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions);
|
||||
toast.error(`An error has occurred : ${errorMessage.message}`, toastOptions);
|
||||
router.replace('/');
|
||||
console.log(`Fail to ${props.mode}`);
|
||||
}
|
||||
|
|
@ -194,23 +172,19 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
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: cleanedSSHKey,
|
||||
sshPublicKey: dataForm.sshkey,
|
||||
comment: dataForm.comment,
|
||||
alert: dataForm.alert.value,
|
||||
lanCommand: dataForm.lanCommand,
|
||||
appendOnlyMode: dataForm.appendOnlyMode,
|
||||
};
|
||||
await fetch('/api/v1/repositories/' + targetRepo?.repositoryName, {
|
||||
await fetch('/api/repo/id/' + router.query.slug + '/edit', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -220,13 +194,13 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'The repository ' + targetRepo?.repositoryName + ' has been successfully edited !',
|
||||
'The repository #' + targetRepo.id + ' has been successfully edited !',
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions);
|
||||
toast.error(`An error has occurred : ${errorMessage.message}`, toastOptions);
|
||||
router.replace('/');
|
||||
console.log(`Fail to ${props.mode}`);
|
||||
}
|
||||
|
|
@ -235,10 +209,6 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -261,54 +231,54 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
color: 'rgba(99, 115, 129, 0.38)',
|
||||
}}
|
||||
>
|
||||
{targetRepo?.repositoryName}
|
||||
#{targetRepo.id}
|
||||
</span>{' '}
|
||||
?
|
||||
</h1>
|
||||
</div>
|
||||
<div className={classes.deleteDialogMessage}>
|
||||
<div style={{ marginBottom: '5px' }}>
|
||||
You are about to permanently delete the repository{' '}
|
||||
<b>{targetRepo?.repositoryName}</b> and all the backups it contains.
|
||||
You are about to permanently delete the repository <b>#{targetRepo.id}</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}>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.formWrapper}>
|
||||
{props.mode == 'edit' && (
|
||||
<h2>
|
||||
<h1>
|
||||
Edit the repository{' '}
|
||||
<span
|
||||
style={{
|
||||
color: '#6d4aff',
|
||||
color: 'rgba(99, 115, 129, 0.38)',
|
||||
}}
|
||||
>
|
||||
{targetRepo?.repositoryName}
|
||||
#{targetRepo.id}
|
||||
</span>
|
||||
</h2>
|
||||
</h1>
|
||||
)}
|
||||
{props.mode == 'add' && <h2>Add a repository</h2>}
|
||||
{props.mode == 'add' && <h1>Add a repository</h1>}
|
||||
<form className={classes.repoManageForm} onSubmit={handleSubmit(formSubmitHandler)}>
|
||||
{/* ALIAS */}
|
||||
<label htmlFor='alias'>Alias</label>
|
||||
|
|
@ -316,16 +286,16 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
className='form-control is-invalid'
|
||||
placeholder='Alias for the repository, e.g."Server 1"'
|
||||
type='text'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.alias : undefined}
|
||||
defaultValue={props.mode == 'edit' ? targetRepo.alias : null}
|
||||
{...register('alias', {
|
||||
required: 'An alias is required.',
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: '1 character min',
|
||||
value: 2,
|
||||
message: '2 characters min',
|
||||
},
|
||||
maxLength: {
|
||||
value: 100,
|
||||
message: '100 characters max',
|
||||
value: 40,
|
||||
message: '40 characters max',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
@ -334,17 +304,15 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
<label htmlFor='sshkey'>SSH public key</label>
|
||||
<textarea
|
||||
placeholder='Public key in OpenSSH format (rsa, ed25519, ed25519-sk)'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.sshPublicKey : undefined}
|
||||
type='text'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo.sshPublicKey : null}
|
||||
{...register('sshkey', {
|
||||
required: 'SSH public key is required.',
|
||||
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)'
|
||||
);
|
||||
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)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
@ -355,9 +323,8 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
<label htmlFor='storageSize'>Storage Size (GB)</label>
|
||||
<input
|
||||
type='number'
|
||||
placeholder='1000'
|
||||
min='1'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.storageSize : undefined}
|
||||
defaultValue={props.mode == 'edit' ? targetRepo.storageSize : null}
|
||||
{...register('storageSize', {
|
||||
required: 'A storage size is required.',
|
||||
})}
|
||||
|
|
@ -368,12 +335,14 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
{/* COMMENT */}
|
||||
<label htmlFor='comment'>Comment</label>
|
||||
<textarea
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.comment : undefined}
|
||||
type='text'
|
||||
placeholder='Little comment for your repository...'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo.comment : null}
|
||||
{...register('comment', {
|
||||
required: false,
|
||||
maxLength: {
|
||||
value: 500,
|
||||
message: '500 characters maximum.',
|
||||
value: 200,
|
||||
message: '200 characters maximum.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
@ -384,11 +353,16 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
<div className={classes.optionCommandWrapper}>
|
||||
<input
|
||||
type='checkbox'
|
||||
defaultChecked={props.mode == 'edit' ? targetRepo?.lanCommand : false}
|
||||
name='lanCommand'
|
||||
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'
|
||||
|
|
@ -400,11 +374,16 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
<div className={classes.optionCommandWrapper}>
|
||||
<input
|
||||
type='checkbox'
|
||||
defaultChecked={props.mode == 'edit' ? targetRepo?.appendOnlyMode : false}
|
||||
name='appendOnlyMode'
|
||||
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'
|
||||
|
|
@ -413,75 +392,57 @@ export default function RepoManage(props: RepoManageProps) {
|
|||
</Link>
|
||||
</div>
|
||||
{/* ALERT */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
className='defaultButton'
|
||||
disabled={!isValid || isSubmitting || isLoading}
|
||||
>
|
||||
{props.mode == 'edit' && 'Save'}
|
||||
{props.mode == 'add' && 'Add repository'}
|
||||
</button>
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<SpinnerDotted size={30} thickness={150} speed={100} color='#6d4aff' />
|
||||
</div>
|
||||
) : (
|
||||
<button type='submit' className='defaultButton' disabled={!isValid || isSubmitting}>
|
||||
{props.mode == 'edit' && 'Edit'}
|
||||
{props.mode == 'add' && 'Add'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
{props.mode == 'edit' ? (
|
||||
<button className={classes.littleDeleteButton} onClick={() => setDeleteDialog(true)}>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
.modale {
|
||||
position: fixed;
|
||||
top: 10%;
|
||||
width: 800px;
|
||||
width: 1000px;
|
||||
height: auto;
|
||||
max-width: 75%;
|
||||
max-height: 85%;
|
||||
|
|
@ -24,11 +24,6 @@
|
|||
animation: append-animate 0.3s linear;
|
||||
}
|
||||
|
||||
.modale h2 {
|
||||
margin-top: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
@keyframes append-animate {
|
||||
from {
|
||||
transform: scale(0);
|
||||
|
|
@ -52,98 +47,89 @@
|
|||
|
||||
.repoManageForm {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
font-family: Inter, sans-serif;
|
||||
color: #1f2937;
|
||||
width: 80%;
|
||||
padding: 15px 30px 30px 30px;
|
||||
border-radius: 5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.formWrapper {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
color: inherit;
|
||||
height: auto;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.repoManageForm label {
|
||||
display: block;
|
||||
margin-top: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
text-align: left;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.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%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
font-family: Inter;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
/* color: #1b1340; */
|
||||
color: #494b7a;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset;
|
||||
}
|
||||
|
||||
.repoManageForm textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.repoManageForm input:focus,
|
||||
.repoManageForm textarea:focus,
|
||||
.repoManageForm input:focus,
|
||||
.repoManageForm select:focus {
|
||||
border-color: #6d4aff;
|
||||
background-color: #ffffff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(109, 74, 255, 0.3);
|
||||
outline: 1px solid #6d4aff;
|
||||
box-shadow: 0 0 10px 3px rgba(110, 74, 255, 0.605);
|
||||
}
|
||||
|
||||
.repoManageForm .invalid {
|
||||
background-color: #fef2f2;
|
||||
border-color: #ef4444;
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
}
|
||||
|
||||
.repoManageForm .invalid:focus {
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3);
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
box-shadow: 0 0 10px 3px rgba(255, 74, 74, 0.605);
|
||||
}
|
||||
|
||||
.repoManageForm button {
|
||||
display: block;
|
||||
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;
|
||||
margin: 15px auto;
|
||||
}
|
||||
|
||||
.repoManageForm button:hover {
|
||||
background-color: #5c3dff;
|
||||
display: block;
|
||||
margin: 15px auto;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.3rem;
|
||||
color: red;
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.optionCommandWrapper {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
margin-top: 20px;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.optionCommandWrapper label {
|
||||
|
|
@ -151,33 +137,15 @@
|
|||
}
|
||||
|
||||
.optionCommandWrapper input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #6d4aff;
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
accent-color: #6d4aff;
|
||||
}
|
||||
|
||||
.optionCommandWrapper input[type='checkbox']:focus {
|
||||
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;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
accent-color: #6d4aff;
|
||||
}
|
||||
|
||||
/* DELETE DIALOG */
|
||||
|
|
@ -286,7 +254,6 @@
|
|||
}
|
||||
|
||||
.littleDeleteButton {
|
||||
margin-top: 10px;
|
||||
border: none;
|
||||
font-weight: 300;
|
||||
color: red;
|
||||
|
|
@ -294,3 +261,8 @@
|
|||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectAlert {
|
||||
margin: auto auto 35px auto;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
|
|
|||
144
Containers/SetupWizard/SetupWizard.js
Normal file
144
Containers/SetupWizard/SetupWizard.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
//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;
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
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;
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
//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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
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,51 +1,44 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { AppriseModeDTO, AppriseModeEnum, Optional } from '~/types';
|
||||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
//Components
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
|
||||
type AppriseModeDataForm = {
|
||||
appriseMode: string;
|
||||
appriseStatelessURL: string;
|
||||
};
|
||||
import Error from '../../../../Components/UI/Error/Error';
|
||||
|
||||
export default function AppriseMode() {
|
||||
//Var
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<AppriseModeDataForm>({ mode: 'onChange' });
|
||||
} = useForm({ mode: 'onBlur' });
|
||||
|
||||
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>>();
|
||||
////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();
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to get Apprise Mode enabled
|
||||
const getAppriseMode = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/mode', {
|
||||
const response = await fetch('/api/account/getAppriseMode', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppriseModeDTO = await response.json();
|
||||
const { appriseStatelessURL, appriseMode } = data;
|
||||
const { appriseStatelessURL, appriseMode } = await response.json();
|
||||
setAppriseMode(appriseMode);
|
||||
|
||||
if (appriseMode == AppriseModeEnum.STATELESS) {
|
||||
if (appriseMode == 'stateless') {
|
||||
setAppriseStatelessURL(appriseStatelessURL);
|
||||
setDisplayStatelessURL(true);
|
||||
}
|
||||
|
|
@ -57,13 +50,15 @@ export default function AppriseMode() {
|
|||
}, []);
|
||||
|
||||
////Functions
|
||||
const modeFormSubmitHandler = async (data: AppriseModeDataForm) => {
|
||||
clearError();
|
||||
setIsLoading(true);
|
||||
start();
|
||||
|
||||
//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
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/mode', {
|
||||
const response = await fetch('/api/account/updateAppriseMode', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -73,15 +68,20 @@ export default function AppriseMode() {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
handleError(result.message);
|
||||
setFormIsLoading(false);
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
handleSuccess();
|
||||
setFormIsLoading(false);
|
||||
setModeFormIsSaved(true);
|
||||
setTimeout(() => setModeFormIsSaved(false), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError('The Apprise mode change has failed');
|
||||
} finally {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
setFormIsLoading(false);
|
||||
setError('Change mode failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -90,9 +90,23 @@ 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} onChange={handleSubmit(modeFormSubmitHandler)}>
|
||||
<form className={classes.bwForm} onBlur={handleSubmit(modeFormSubmitHandler)}>
|
||||
<div className='radio-group'>
|
||||
<label style={{ marginRight: '50px' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
|
|
@ -102,7 +116,7 @@ export default function AppriseMode() {
|
|||
value='package'
|
||||
onClick={() => {
|
||||
setDisplayStatelessURL(false);
|
||||
setAppriseMode(AppriseModeEnum.PACKAGE);
|
||||
setAppriseMode('package');
|
||||
}}
|
||||
checked={appriseMode == 'package' ? true : false}
|
||||
/>
|
||||
|
|
@ -117,7 +131,7 @@ export default function AppriseMode() {
|
|||
type='radio'
|
||||
onClick={() => {
|
||||
setDisplayStatelessURL(true);
|
||||
setAppriseMode(AppriseModeEnum.STATELESS);
|
||||
setAppriseMode('stateless');
|
||||
}}
|
||||
checked={appriseMode == 'stateless' ? true : false}
|
||||
/>
|
||||
|
|
@ -1,67 +1,65 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { AppriseServicesDTO, Optional } from '~/types';
|
||||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
//Components
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
|
||||
type AppriseURLsDataForm = {
|
||||
appriseURLs: string;
|
||||
};
|
||||
import Error from '../../../../Components/UI/Error/Error';
|
||||
|
||||
export default function AppriseURLs() {
|
||||
//Var
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<AppriseURLsDataForm>({ mode: 'onBlur' });
|
||||
} = useForm({ mode: 'onBlur' });
|
||||
|
||||
const { isSaved, error, handleSuccess, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const [appriseServicesList, setAppriseServicesList] = useState<Optional<string>>();
|
||||
const [fetchError, setFetchError] = useState<Optional<boolean>>();
|
||||
////State
|
||||
const [formIsLoading, setFormIsLoading] = useState(false);
|
||||
const [urlsFormIsSaved, setUrlsFormIsSaved] = useState(false);
|
||||
const [appriseServicesList, setAppriseServicesList] = useState();
|
||||
const [error, setError] = useState();
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to build the list of Apprise Services enabled
|
||||
const getAppriseServices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/services', {
|
||||
const response = await fetch('/api/account/getAppriseServices', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppriseServicesDTO = await response.json();
|
||||
const servicesText = data.appriseServices?.join('\n');
|
||||
setAppriseServicesList(servicesText);
|
||||
setFetchError(false);
|
||||
let servicesArray = (await response.json()).appriseServices;
|
||||
const AppriseServicesListToText = () => {
|
||||
let list = '';
|
||||
for (let service of servicesArray) {
|
||||
list += service + '\n';
|
||||
}
|
||||
return list;
|
||||
};
|
||||
setAppriseServicesList(AppriseServicesListToText());
|
||||
} catch (error) {
|
||||
setFetchError(true);
|
||||
handleError('Fetching Apprise services list failed.');
|
||||
console.log('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: AppriseURLsDataForm) => {
|
||||
clearError();
|
||||
start();
|
||||
if (fetchError) {
|
||||
handleError('Cannot update Apprise services. Failed to fetch the initial list.');
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const urlsFormSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setFormIsLoading(true);
|
||||
//POST API to update Apprise Services
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/services', {
|
||||
const response = await fetch('/api/account/updateAppriseServices', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -71,14 +69,20 @@ export default function AppriseURLs() {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
handleError(result.message);
|
||||
setFormIsLoading(false);
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
handleSuccess();
|
||||
setFormIsLoading(false);
|
||||
setUrlsFormIsSaved(true);
|
||||
setTimeout(() => setUrlsFormIsSaved(false), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError('Failed to update your Apprise services.');
|
||||
} finally {
|
||||
stop();
|
||||
setFormIsLoading(false);
|
||||
setError('Failed to update your services. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -87,8 +91,18 @@ 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' }}>
|
||||
{isSaved && (
|
||||
{formIsLoading && (
|
||||
<SpinnerCircularFixed
|
||||
size={18}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
)}
|
||||
{urlsFormIsSaved && (
|
||||
<div className={classes.formIsSavedMessage}>
|
||||
✅ Apprise configuration has been saved.
|
||||
</div>
|
||||
|
|
@ -101,6 +115,7 @@ export default function AppriseURLs() {
|
|||
>
|
||||
<textarea
|
||||
style={{ height: '100px' }}
|
||||
type='text'
|
||||
placeholder={
|
||||
'matrixs://{user}:{password}@{matrixhost}\ndiscord://{WebhookID}/{WebhookToken}\nmmosts://user@hostname/authkey'
|
||||
}
|
||||
|
|
@ -136,7 +151,6 @@ export default function AppriseURLs() {
|
|||
</a>{' '}
|
||||
to send a notification to any service. Only one URL per line.
|
||||
</div>
|
||||
{error && <Error message={error} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
Containers/UserSettings/EmailAlertSettings/EmailAlertSettings.js
Normal file
191
Containers/UserSettings/EmailAlertSettings/EmailAlertSettings.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
//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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
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 @@
|
|||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
//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';
|
||||
|
||||
//Components
|
||||
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';
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Info from '../../../Components/UI/Info/Info';
|
||||
|
||||
export default function EmailSettings(props: EmailSettingDTO) {
|
||||
const toastOptions: ToastOptions = {
|
||||
export default function EmailSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -26,23 +26,24 @@ export default function EmailSettings(props: EmailSettingDTO) {
|
|||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<EmailSettingDTO>({ mode: 'onChange' });
|
||||
|
||||
const { isLoading, error, setIsLoading, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////Functions
|
||||
const formSubmitHandler = async (data: EmailSettingDTO) => {
|
||||
start();
|
||||
clearError();
|
||||
//Form submit Handler for ADD a repo
|
||||
const formSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setIsLoading(true);
|
||||
|
||||
//POST API to send the new mail address
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/email', {
|
||||
const response = await fetch('/api/account/updateEmail', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -52,8 +53,10 @@ export default function EmailSettings(props: EmailSettingDTO) {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
handleError(result.message);
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
|
|
@ -62,10 +65,9 @@ export default function EmailSettings(props: EmailSettingDTO) {
|
|||
}
|
||||
} 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 (
|
||||
|
|
@ -107,9 +109,13 @@ export default function EmailSettings(props: EmailSettingDTO) {
|
|||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={isSubmitting || isLoading}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
Update your email
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={20} thickness={150} speed={100} color='#fff' />
|
||||
) : (
|
||||
'Update your email'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
|
@ -1,26 +1,23 @@
|
|||
import { IconExternalLink, IconTrash } from '@tabler/icons-react';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
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';
|
||||
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';
|
||||
|
||||
//Components
|
||||
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;
|
||||
};
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import CopyButton from '../../../Components/UI/CopyButton/CopyButton';
|
||||
import Info from '../../../Components/UI/Info/Info';
|
||||
|
||||
export default function Integrations() {
|
||||
const toastOptions: ToastOptions = {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -35,28 +32,16 @@ export default function Integrations() {
|
|||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = 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>
|
||||
));
|
||||
};
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
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>({
|
||||
const [tokenList, setTokenList] = useState([]);
|
||||
const [error, setError] = useState();
|
||||
const [lastGeneratedToken, setLastGeneratedToken] = useState();
|
||||
const [deletingToken, setDeletingToken] = useState(null);
|
||||
const [permissions, setPermissions] = useState({
|
||||
create: false,
|
||||
read: false,
|
||||
update: false,
|
||||
|
|
@ -64,34 +49,30 @@ export default function Integrations() {
|
|||
});
|
||||
|
||||
const fetchTokenList = async () => {
|
||||
start();
|
||||
try {
|
||||
const response = await fetch('/api/v1/integration/token-manager', {
|
||||
const response = await fetch('/api/account/tokenManager', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const data: Array<IntegrationTokenType> = await response.json();
|
||||
setTokenList(data);
|
||||
const tokensArray = await response.json();
|
||||
setTokenList(tokensArray);
|
||||
} catch (error) {
|
||||
handleError('Fetching token list failed.');
|
||||
} finally {
|
||||
stop();
|
||||
console.log('Fetching token list failed.');
|
||||
}
|
||||
};
|
||||
|
||||
////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: TokenPermissionEnum) => {
|
||||
const togglePermission = (permissionType) => {
|
||||
const updatedPermissions = {
|
||||
...permissions,
|
||||
[permissionType]: !permissions[permissionType],
|
||||
|
|
@ -107,48 +88,60 @@ export default function Integrations() {
|
|||
});
|
||||
};
|
||||
|
||||
//Form submit handler to ADD a new token
|
||||
const formSubmitHandler = async (data: IntegrationsDataForm) => {
|
||||
start();
|
||||
clearError();
|
||||
//Form submit Handler for ADD a new token
|
||||
const formSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
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/v1/integration/token-manager', {
|
||||
const response = await fetch('/api/account/tokenManager', {
|
||||
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) {
|
||||
toast.error('Failed to generate a new token', toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
resetPermissions();
|
||||
reset();
|
||||
stop();
|
||||
resetPermissions();
|
||||
setIsLoading(false);
|
||||
toast.error("Can't generate your token. Contact your administrator.", toastOptions);
|
||||
setTimeout(() => setError(), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
//Delete token
|
||||
const deleteTokenHandler = async (tokenName: string) => {
|
||||
const deleteTokenHandler = async (tokenName) => {
|
||||
setIsDeleteLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/integration/token-manager', {
|
||||
const response = await fetch('/api/account/tokenManager', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -161,6 +154,7 @@ export default function Integrations() {
|
|||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
setTimeout(() => setError(), 4000);
|
||||
setIsDeleteLoading(false);
|
||||
} else {
|
||||
fetchTokenList();
|
||||
|
|
@ -169,10 +163,11 @@ export default function Integrations() {
|
|||
}
|
||||
} catch (error) {
|
||||
setIsDeleteLoading(false);
|
||||
toast.error('Failed to delete the token', toastOptions);
|
||||
toast.error("Can't delete your token. Contact your administrator.", toastOptions);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} finally {
|
||||
setIsDeleteLoading(false);
|
||||
setDeletingToken(undefined);
|
||||
setDeletingToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -210,25 +205,25 @@ export default function Integrations() {
|
|||
<div className={classes.permissionsWrapper}>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.create ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.CREATE)}
|
||||
onClick={() => togglePermission('create')}
|
||||
>
|
||||
Create
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.read ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.READ)}
|
||||
onClick={() => togglePermission('read')}
|
||||
>
|
||||
Read
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.update ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.UPDATE)}
|
||||
onClick={() => togglePermission('update')}
|
||||
>
|
||||
Update
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.delete ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.DELETE)}
|
||||
onClick={() => togglePermission('delete')}
|
||||
>
|
||||
Delete
|
||||
</div>
|
||||
|
|
@ -239,7 +234,11 @@ export default function Integrations() {
|
|||
className={classes.AccountSettingsButton}
|
||||
disabled={!isValid || isSubmitting || hasNoPermissionSelected()}
|
||||
>
|
||||
Generate
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={15} thickness={150} speed={100} color='#fff' />
|
||||
) : (
|
||||
'Generate'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{errors.tokenName && errors.tokenName.type === 'maxLength' && (
|
||||
|
|
@ -273,19 +272,25 @@ export default function Integrations() {
|
|||
>
|
||||
<div className={classes.tokenCardHeader}>{token.name}</div>
|
||||
<div className={classes.tokenCardBody}>
|
||||
<div className={classes.tokenInfo}>
|
||||
<p>
|
||||
<strong>Created at:</strong>
|
||||
{fromUnixTime(token.creation).toLocaleString()}
|
||||
</div>
|
||||
<div className={classes.tokenInfo}>
|
||||
{timestampConverter(token.creation)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Permission:</strong>
|
||||
<div className={classes.permissionBadges}>
|
||||
{renderPermissionBadges(token.permissions)}
|
||||
{Object.keys(token.permissions).map((permission) =>
|
||||
token.permissions[permission] ? (
|
||||
<div key={permission} className={classes.permissionBadge}>
|
||||
{permission.charAt(0).toUpperCase() + permission.slice(1)}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
{lastGeneratedToken && lastGeneratedToken.name === token.name && (
|
||||
<>
|
||||
<div className={classes.tokenInfo}>
|
||||
<p>
|
||||
<strong>Token:</strong>
|
||||
<CopyButton
|
||||
size={22}
|
||||
|
|
@ -294,11 +299,10 @@ export default function Integrations() {
|
|||
>
|
||||
<span>{lastGeneratedToken.value}</span>
|
||||
</CopyButton>
|
||||
</div>
|
||||
<Info
|
||||
color='#3498db'
|
||||
message='This token will not be shown again. Please save it.'
|
||||
/>
|
||||
</p>
|
||||
<Info color='#3498db'>
|
||||
This token will not be shown again. Please save it.
|
||||
</Info>
|
||||
</>
|
||||
)}
|
||||
{deletingToken && deletingToken.name === token.name && (
|
||||
|
|
@ -309,11 +313,14 @@ 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(undefined)}
|
||||
onClick={() => setDeletingToken(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -1,15 +1,17 @@
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { PasswordSettingDTO } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
|
||||
//Components
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
|
||||
export default function PasswordSettings() {
|
||||
const toastOptions: ToastOptions = {
|
||||
export default function PasswordSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -23,19 +25,24 @@ export default function PasswordSettings() {
|
|||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<PasswordSettingDTO>({ mode: 'onChange' });
|
||||
const { start, stop } = useLoader();
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
const { isLoading, setIsLoading } = useFormStatus();
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
|
||||
////Functions
|
||||
const formSubmitHandler = async (data: PasswordSettingDTO) => {
|
||||
start();
|
||||
//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.
|
||||
setIsLoading(true);
|
||||
|
||||
//POST API to send the new and old password
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/password', {
|
||||
const response = await fetch('/api/account/updatePassword', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -45,16 +52,20 @@ export default function PasswordSettings() {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} 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 (
|
||||
|
|
@ -67,6 +78,7 @@ export default function PasswordSettings() {
|
|||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
<form onSubmit={handleSubmit(formSubmitHandler)} className={classes.bwForm}>
|
||||
{error && <Error message={error} />}
|
||||
<p>
|
||||
<input
|
||||
type='password'
|
||||
|
|
@ -75,6 +87,9 @@ export default function PasswordSettings() {
|
|||
required: true,
|
||||
})}
|
||||
/>
|
||||
{errors.oldPassword && errors.oldPassword.type === 'required' && (
|
||||
<small className={classes.errorMessage}>This field is required.</small>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<input
|
||||
|
|
@ -84,12 +99,16 @@ export default function PasswordSettings() {
|
|||
required: true,
|
||||
})}
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<small className={classes.errorMessage}>This field is required.</small>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
Update your password
|
||||
<button className={classes.AccountSettingsButton} disabled={!isValid || isSubmitting}>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={20} thickness={150} speed={100} color='#fff' />
|
||||
) : (
|
||||
'Update your password'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
92
Containers/UserSettings/UserSettings.js
Normal file
92
Containers/UserSettings/UserSettings.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//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;
|
||||
}
|
||||
|
||||
.tokenInfo {
|
||||
.tokenCardBody p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
|
@ -387,7 +387,7 @@
|
|||
.headerFormAppriseUrls {
|
||||
font-weight: 500;
|
||||
color: #494b7a;
|
||||
margin-bottom: 10px;
|
||||
margin: 40px 0px 10px 0px;
|
||||
display: flex;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
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,17 +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 { 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';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
|
||||
//Components
|
||||
import Info from '~/Components/UI/Info/Info';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Info from '../../../Components/UI/Info/Info';
|
||||
|
||||
export default function UsernameSettings(props: UsernameSettingDTO) {
|
||||
const toastOptions: ToastOptions = {
|
||||
export default function UsernameSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
|
|
@ -25,22 +26,24 @@ export default function UsernameSettings(props: UsernameSettingDTO) {
|
|||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UsernameSettingDTO>({ mode: 'onChange' });
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const { isLoading, setIsLoading } = useFormStatus();
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////Functions
|
||||
const formSubmitHandler = async (data: UsernameSettingDTO) => {
|
||||
start();
|
||||
//Form submit Handler for ADD a repo
|
||||
const formSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setIsLoading(true);
|
||||
|
||||
//POST API to update the username
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/username', {
|
||||
const response = await fetch('/api/account/updateUsername', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
|
|
@ -50,17 +53,21 @@ export default function UsernameSettings(props: UsernameSettingDTO) {
|
|||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} 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 (
|
||||
|
|
@ -77,29 +84,30 @@ export default function UsernameSettings(props: UsernameSettingDTO) {
|
|||
//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]{1,40}$/,
|
||||
message: 'Only a-z characters are allowed',
|
||||
value: /^[a-z]{5,15}$/,
|
||||
message: 'Only a-z characters are allowed.',
|
||||
},
|
||||
maxLength: {
|
||||
value: 40,
|
||||
message: '40 characters max.',
|
||||
value: 10,
|
||||
message: '15 characters max.',
|
||||
},
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: '1 characters min.',
|
||||
value: 5,
|
||||
message: '5 characters min.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
@ -109,9 +117,13 @@ export default function UsernameSettings(props: UsernameSettingDTO) {
|
|||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={isLoading || isSubmitting}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
Update your username
|
||||
{isLoading ? (
|
||||
<SpinnerDotted size={20} thickness={150} speed={100} color='#fff' />
|
||||
) : (
|
||||
'Update your username'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
22
Dockerfile
22
Dockerfile
|
|
@ -1,34 +1,28 @@
|
|||
ARG UID=1001
|
||||
ARG GID=1001
|
||||
|
||||
FROM node:22-bookworm-slim as base
|
||||
FROM node:20-bookworm-slim as base
|
||||
|
||||
# build stage
|
||||
FROM base AS deps
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN npm ci --only=production
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN sed -i "s/images:/output: 'standalone',images:/" next.config.ts
|
||||
RUN sed -i "s/images:/output: 'standalone',images:/" next.config.js
|
||||
|
||||
RUN pnpm run build
|
||||
RUN npm run build
|
||||
|
||||
# run stage
|
||||
FROM base AS runner
|
||||
|
|
@ -37,11 +31,10 @@ ARG UID
|
|||
ARG GID
|
||||
|
||||
ENV NODE_ENV production
|
||||
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 && \
|
||||
supervisor curl jq jc borgbackup/bookworm-backports openssh-server rsyslog && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -g ${GID} borgwarehouse && useradd -m -u ${UID} -g ${GID} borgwarehouse
|
||||
|
|
@ -56,6 +49,7 @@ 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
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -1,19 +1,16 @@
|
|||
<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>
|
||||
|
||||
<img src="public/borgwarehouse-logo-violet.svg" alt="BorgWarehouse" style="margin: 30px 0">
|
||||
<h3 align="center">BorgWarehouse</h3>
|
||||
|
||||
<p align="center">
|
||||
A fast and modern WebUI for a BorgBackup's central repository server.
|
||||
|
|
@ -23,17 +20,17 @@
|
|||
|
||||
<div align="center">
|
||||
<a href="https://borgwarehouse.com">
|
||||
<img src="medias/borgwarehouse-og.jpg" alt="presentation">
|
||||
<img src="medias/borgwarehouse-og.png" 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 ?
|
||||
|
|
@ -44,14 +41,13 @@ 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
|
||||
- manage everything you want through the **REST API**
|
||||
- ...
|
||||
- **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
|
||||
- ...
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -75,22 +71,13 @@ 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/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
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
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,6 +20,8 @@ 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
|
||||
|
|
|
|||
40
docker/rsyslog.conf
Normal file
40
docker/rsyslog.conf
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# 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,21 +1,24 @@
|
|||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/dev/stdout
|
||||
logfile_maxbytes=0
|
||||
logfile=/home/borgwarehouse/logs/supervisord.log
|
||||
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=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
redirect_stderr=false
|
||||
stdout_logfile=/home/borgwarehouse/tmp/sshd.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
redirect_stderr=true
|
||||
|
||||
[program:borgwarehouse]
|
||||
command=/usr/local/bin/node server.js
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
redirect_stderr=false
|
||||
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
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
11
helpers/functions/auth.js
Normal file
11
helpers/functions/auth.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// 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);
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import lanCommandOption from './lanCommandOption';
|
||||
import isSshPubKeyDuplicate from './isSshPubKeyDuplicate';
|
||||
|
||||
export { lanCommandOption, isSshPubKeyDuplicate };
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { Optional, Repository } from '~/types';
|
||||
|
||||
/**
|
||||
* Checks if the given SSH public key is duplicated in the provided repository list by removing the comment part.
|
||||
*
|
||||
|
|
@ -8,10 +6,7 @@ import { Optional, Repository } from '~/types';
|
|||
* @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: string,
|
||||
repoList: Array<Optional<Repository>>
|
||||
): boolean {
|
||||
export default function isSshPubKeyDuplicate(pubKey, repoList) {
|
||||
if (!pubKey || !repoList || !Array.isArray(repoList)) {
|
||||
throw new Error('Missing or invalid parameters for duplicate SSH public key check.');
|
||||
}
|
||||
|
|
@ -21,7 +16,7 @@ export default function isSshPubKeyDuplicate(
|
|||
|
||||
// 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,70 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
13
helpers/functions/lanCommandOption.js
Normal file
13
helpers/functions/lanCommandOption.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
18
helpers/functions/nodemailerSMTP.js
Normal file
18
helpers/functions/nodemailerSMTP.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//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;
|
||||
}
|
||||
36
helpers/functions/repoHistory.js
Normal file
36
helpers/functions/repoHistory.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
12
helpers/functions/timestampConverter.js
Normal file
12
helpers/functions/timestampConverter.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// 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;
|
||||
}
|
||||
34
helpers/functions/tokenController.js
Normal file
34
helpers/functions/tokenController.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -31,8 +31,8 @@ pool="${home}/repos"
|
|||
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]" >&2
|
||||
if [ "$1" == "" ] || [ "$2" == "" ] || [ "$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]"
|
||||
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)" >&2
|
||||
echo -n "Invalid public SSH KEY format. Provide a key in OpenSSH format (rsa, ed25519, ed25519-sk)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
## Check if authorized_keys exists
|
||||
if [ ! -f "${authorized_keys}" ];then
|
||||
echo -n "${authorized_keys} must be present" >&2
|
||||
echo -n "${authorized_keys} must be present"
|
||||
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" >&2
|
||||
echo -n "SSH pub key already present in authorized_keys"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Check if borgbackup is installed
|
||||
if ! [ -x "$(command -v borg)" ]; then
|
||||
echo -n "You must install borgbackup package." >&2
|
||||
echo -n "You must install borgbackup package."
|
||||
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-repository ${pool}/${repositoryName} --storage-quota $2G\",restrict $1"
|
||||
restricted_authkeys="command=\"cd ${pool};borg serve${appendOnlyMode} --restrict-to-path ${pool}/${repositoryName} --storage-quota $2G\",restrict $1"
|
||||
echo "$restricted_authkeys" | tee -a "${authorized_keys}" >/dev/null
|
||||
|
||||
## Return the repositoryName
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
#!/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.
|
||||
|
|
@ -23,16 +21,16 @@ authorized_keys="${home}/.ssh/authorized_keys"
|
|||
|
||||
# Check arg
|
||||
if [[ $# -ne 1 || $1 = "" ]]; then
|
||||
echo -n "You must provide a repositoryName in argument." >&2
|
||||
echo -n "You must provide a repositoryName in argument."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the repositoryName pattern is an hexa 8 char. With createRepo.sh our randoms are hexa of 8 characters.
|
||||
# If we receive another pattern there is necessarily a problem.
|
||||
# Check if the repositoryName length is 8 char. With createRepo.sh our randoms have a length of 8 characters.
|
||||
# If we receive another length 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." >&2
|
||||
exit 2
|
||||
if [ ${#repositoryName} != 8 ]; then
|
||||
echo -n "Error with the length of the repositoryName."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Delete the repository and the line associated in the authorized_keys file
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
#!/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,7 +1,5 @@
|
|||
#!/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 :
|
||||
|
|
@ -16,9 +14,6 @@
|
|||
# Exit when any command fails
|
||||
set -e
|
||||
|
||||
# Ignore "lost+found" directories
|
||||
GLOBIGNORE="LOST+FOUND:lost+found"
|
||||
|
||||
# Load .env if exists
|
||||
if [[ -f .env ]]; then
|
||||
source .env
|
||||
|
|
@ -27,12 +22,6 @@ fi
|
|||
# Default value if .env not exists
|
||||
: "${home:=/home/borgwarehouse}"
|
||||
|
||||
# Get the size of each repository and format as JSON
|
||||
# Use jc to output a JSON format with du command
|
||||
cd "${home}"/repos
|
||||
output=$(du -s -L -- * 2>/dev/null | awk '{print "{\"size\":" $1 ",\"name\":\"" $2 "\"}"}' | jq -s '.')
|
||||
if [ -z "$output" ]; then
|
||||
output="[]"
|
||||
fi
|
||||
|
||||
# Print the JSON output
|
||||
echo "$output"
|
||||
du -s -- * | jc --du
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
#!/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.
|
||||
|
|
@ -19,7 +17,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]]" >&2
|
||||
echo -n "This shell takes 4 args: [repositoryName] [new SSH pub key] [quota] [Append only mode [true|false]]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -28,21 +26,21 @@ 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)" >&2
|
||||
echo -n "Invalid public SSH KEY format. Provide a key in OpenSSH format (rsa, ed25519, ed25519-sk)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Check if the repositoryName pattern is an hexa 8 char. With createRepo.sh our randoms are hexa of 8 characters.
|
||||
# If we receive another pattern there is necessarily a problem.
|
||||
# Check if repositoryName length is 8 char. With createRepo.sh our randoms have a length of 8 characters.
|
||||
# If we receive another length, 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." >&2
|
||||
if [ ${#repositoryName} != 8 ]; then
|
||||
echo -n "Error with the length of the repositoryName."
|
||||
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" >&2
|
||||
echo -n "No line containing $repositoryName found in authorized_keys"
|
||||
exit 4
|
||||
fi
|
||||
|
||||
|
|
@ -66,7 +64,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." >&2
|
||||
echo -n "This SSH pub key is already present in authorized_keys on a different line."
|
||||
exit 5
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import path from 'path';
|
||||
|
||||
export default function emailTest(mailTo: string, username: string, aliasList: string[]) {
|
||||
const aliasTemplate = (x: string[]) => {
|
||||
export default function emailTest(mailTo, username, aliasList) {
|
||||
const aliasTemplate = (x) => {
|
||||
let str = '';
|
||||
for (const alias of x) {
|
||||
str = str + '<li>' + alias + '</li>';
|
||||
|
|
@ -128,7 +126,7 @@ export default function emailTest(mailTo: string, username: string, aliasList: s
|
|||
`,
|
||||
attachments: [
|
||||
{
|
||||
path: path.join(process.cwd(), 'helpers/templates/attachments/alert-icon.png'),
|
||||
path: 'helpers/templates/attachments/alert-icon.png',
|
||||
cid: 'alert-icon',
|
||||
},
|
||||
],
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import path from 'path';
|
||||
|
||||
export default function emailTest(mailTo: string, username: string) {
|
||||
export default function emailTest(mailTo, username) {
|
||||
const template = {
|
||||
from: 'BorgWarehouse' + '<' + process.env.MAIL_SMTP_FROM + '>',
|
||||
to: mailTo,
|
||||
|
|
@ -96,7 +94,7 @@ export default function emailTest(mailTo: string, username: string) {
|
|||
`,
|
||||
attachments: [
|
||||
{
|
||||
path: path.join(process.cwd(), 'helpers/templates/attachments/valid-icon.png'),
|
||||
path: 'helpers/templates/attachments/valid-icon.png',
|
||||
cid: 'valid-icon',
|
||||
},
|
||||
],
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './useFormStatus';
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 246 KiB |
BIN
medias/borgwarehouse-og.png
Normal file
BIN
medias/borgwarehouse-og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 557 KiB |
6
next-env.d.ts
vendored
6
next-env.d.ts
vendored
|
|
@ -1,6 +0,0 @@
|
|||
/// <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.
|
||||
25
next.config.js
Normal file
25
next.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
|
||||
module.exports = {
|
||||
// nextConfig
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
//basePath: '/borgwarehouse-demo',
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/setup-wizard',
|
||||
destination: '/setup-wizard/1',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/manage-repo',
|
||||
destination: '/',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/setup-wizard',
|
||||
destination: '/setup-wizard/1',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/manage-repo',
|
||||
destination: '/',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6383
package-lock.json
generated
Normal file
6383
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
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