Compare commits

...

114 Commits

Author SHA1 Message Date
Ildar Kamalov
76bf99499a Merge branch 'master' into ADG-9415 2025-02-26 18:31:41 +03:00
Ainar Garipov
29529970a3 Merge branch 'master' into ADG-9415 2025-02-24 15:44:38 +03:00
Ildar Kamalov
b49790daf8 fix default lease duration value 2025-02-24 15:30:18 +03:00
Ildar Kamalov
cb307472ec fix default response status 2025-02-24 10:35:26 +03:00
Ildar Kamalov
115e743e1a fix upstream description 2025-02-24 10:32:46 +03:00
Ildar Kamalov
26b0eddaca use const for test config file 2025-02-18 17:40:41 +03:00
Ildar Kamalov
58faa7c537 fix install config 2025-02-18 17:31:04 +03:00
Ildar Kamalov
0a3346d911 fix install check config 2025-02-17 15:25:23 +03:00
Ildar Kamalov
17c4c26ea8 fix query log 2025-02-14 17:18:20 +03:00
Ildar Kamalov
14a2685ae3 fix dhcp initial values 2025-02-14 15:52:36 +03:00
Ildar Kamalov
e7a8db7afd fix encryption form values 2025-02-14 14:37:24 +03:00
Ildar Kamalov
1c8917f7ac fix blocked services submit 2025-02-14 14:07:29 +03:00
Ildar Kamalov
4dfa536cea dns config ip validation 2025-02-14 13:50:47 +03:00
Ildar Kamalov
4fee83fe13 add playwright warning 2025-02-12 17:49:54 +03:00
Ildar Kamalov
8c2f36e7a6 fix config file name 2025-02-11 18:36:18 +03:00
Ildar Kamalov
83db5f33dc temp config file 2025-02-11 16:16:43 +03:00
Ildar Kamalov
9080c1620f update readme 2025-02-11 15:01:46 +03:00
Ildar Kamalov
ee1520307f Merge branch 'master' into ADG-9415 2025-02-11 14:44:06 +03:00
Igor Lobanov
fd12e33c06 added typecheck on build, fixed eslint 2025-02-10 10:29:43 +01:00
Igor Lobanov
b3849eebc4 Merge branch 'ADG-9415' of https://bit.int.agrd.dev/scm/dns/adguard-home into ADG-9415 2025-02-10 09:43:32 +01:00
Ildar Kamalov
9bf3ee128b fix playwright version 2025-02-07 15:58:54 +03:00
Ildar Kamalov
a411421712 install deps 2025-02-07 15:47:25 +03:00
Ildar Kamalov
f8fbf96547 Merge branch 'master' into ADG-9415 2025-02-07 15:00:58 +03:00
Ildar Kamalov
391d577aca update docker frontend version 2025-02-07 12:41:10 +03:00
Ildar Kamalov
0b52ef1d0a Merge branch 'master' into ADG-9415 2025-02-06 12:22:37 +03:00
Ildar Kamalov
8b6d785c72 fix encryption validation 2025-02-05 17:52:45 +03:00
Igor Lobanov
225167a8bb cleanup 2025-02-05 11:35:42 +01:00
Igor Lobanov
6fef2df6b9 do not remove config on local e2e run 2025-02-05 11:18:29 +01:00
Ildar Kamalov
42846cce08 review fix 2025-02-05 11:51:34 +03:00
Igor Lobanov
c3f52d1f84 removed debug code 2025-02-04 09:25:05 +01:00
Igor Lobanov
9c692da2b7 refactor network helper for e2e tests 2025-02-04 09:24:51 +01:00
Igor Lobanov
84130a6419 refactor network helper for e2e tests 2025-02-04 09:18:26 +01:00
Igor Lobanov
55567a33bc adjust test data 2025-02-03 23:38:45 +01:00
Igor Lobanov
3cfc3d2327 debug playwright 2025-02-03 20:14:02 +01:00
Igor Lobanov
15cb5ed90a github node version bump 2025-02-03 19:27:28 +01:00
Igor Lobanov
126f1e2127 removed the rest of ignore flags 2025-02-03 19:23:00 +01:00
Igor Lobanov
4188c4c64e removed ignore platform flag for npm install 2025-02-03 19:11:54 +01:00
Igor Lobanov
14adad7055 removed ignore optional flag for npm install 2025-02-03 19:08:30 +01:00
Igor Lobanov
d58e823c1c fix command 2025-02-03 18:57:10 +01:00
Igor Lobanov
8b49a82ac4 debug 2025-02-03 18:00:22 +01:00
Ildar Kamalov
280c1ab830 docker frontend 2025-02-03 19:47:05 +03:00
Igor Lobanov
b3c05abbdb adjusting playwright install 2025-02-03 13:34:05 +01:00
Igor Lobanov
4c66e04d28 Merge branch 'ADG-9415' of https://bit.int.agrd.dev/scm/dns/adguard-home into ADG-9415 2025-02-03 13:23:40 +01:00
Igor Lobanov
d076ada956 paywright install move to bamboo spec (temp) 2025-02-03 13:22:35 +01:00
Ildar Kamalov
f1008ab7a5 remove unknown command 2025-02-03 15:18:11 +03:00
Ildar Kamalov
e34fc6ac5e remove with deps flag 2025-02-03 15:04:29 +03:00
Ildar Kamalov
f218cbccfd add playwright install checks 2025-02-03 14:52:25 +03:00
Ildar Kamalov
f645ff30bc e2e deps 2025-02-03 14:30:05 +03:00
Ildar Kamalov
5b73b53ac2 add playwright install 2025-02-03 14:18:22 +03:00
Ildar Kamalov
56180d4e40 js-deps 2025-02-03 14:09:25 +03:00
Ildar Kamalov
8d22b72834 add build output 2025-02-03 14:04:47 +03:00
Ildar Kamalov
80fb6013f7 frontend artifact 2025-02-03 14:02:20 +03:00
Ainar Garipov
1fe5fd2ed6 home: imp logs 2025-02-03 13:44:44 +03:00
Ildar Kamalov
5105d35e72 revert process in the background 2025-02-03 13:35:57 +03:00
Ildar Kamalov
d08945811e rm sudo 2025-02-03 13:23:40 +03:00
Ildar Kamalov
f89a24d6e9 run adguard home in the background 2025-02-03 13:16:15 +03:00
Igor Lobanov
2aff577658 debug 2025-01-31 17:45:53 +01:00
Igor Lobanov
5461fa9281 increase timeout 2025-01-31 17:32:19 +01:00
Ildar Kamalov
952c2f4f30 increase timeout 2025-01-31 19:24:29 +03:00
Ildar Kamalov
df20c590d3 increase timeout 2025-01-31 19:13:23 +03:00
Ildar Kamalov
66ac10fa2e fix directory 2025-01-31 19:05:45 +03:00
Ildar Kamalov
c4c58c0eed mv binary to the project root 2025-01-31 18:46:20 +03:00
Ildar Kamalov
215036b4bc mv binary to root 2025-01-31 18:37:02 +03:00
Ildar Kamalov
216ba0fdde stdout pipe 2025-01-31 18:33:39 +03:00
Ildar Kamalov
f5d6c50874 revert artifact name 2025-01-31 18:14:15 +03:00
Ildar Kamalov
dff6ac859a fix file name 2025-01-31 18:01:28 +03:00
Ildar Kamalov
8657483022 try darwin artifact 2025-01-31 17:47:03 +03:00
Ildar Kamalov
275677f494 web server command with sudo 2025-01-31 17:36:39 +03:00
Ildar Kamalov
8a13ca8ab8 list file 2025-01-31 17:06:50 +03:00
Ildar Kamalov
7f9c33db32 fix property name 2025-01-31 16:51:12 +03:00
Ildar Kamalov
58174d0364 use artifact-subscriptions 2025-01-31 16:49:41 +03:00
Ildar Kamalov
bac9991a95 add e2e stage 2025-01-31 16:44:20 +03:00
Ildar Kamalov
86ac5e5567 add ci to process env 2025-01-31 16:16:09 +03:00
Ildar Kamalov
3d6cca333c try to install deps 2025-01-31 16:00:39 +03:00
Ildar Kamalov
236708f2e4 try to add e2e job 2025-01-31 15:37:49 +03:00
Ildar Kamalov
7f85a04257 try to run e2e tests 2025-01-31 12:42:57 +03:00
Ildar Kamalov
e42c051ab7 replace jest with vitest 2025-01-31 11:56:38 +03:00
Ildar Kamalov
77d05e550e fix tests 2025-01-29 18:28:34 +03:00
Ildar Kamalov
885e24496c Merge branch 'master' into ADG-9415 2025-01-29 15:30:03 +03:00
Ildar Kamalov
47216d6c05 encryption 2025-01-28 16:50:30 +03:00
Ildar Kamalov
6cb3d85d01 fix forms 2025-01-27 13:29:07 +03:00
Ildar Kamalov
09b15210ed fix filters form 2025-01-27 10:51:49 +03:00
Ildar Kamalov
5279124130 fix static leases form 2025-01-27 10:47:43 +03:00
Ildar Kamalov
eb9b9b6c2b fix dhcp form 2025-01-24 17:09:54 +03:00
Ildar Kamalov
f78dc10c2a fix forms 2025-01-24 16:42:30 +03:00
Ildar Kamalov
f3f38e1a57 fix login 2025-01-24 15:26:38 +03:00
Ildar Kamalov
9d5042b1f0 fix install types and test ids 2025-01-24 15:15:07 +03:00
Ildar Kamalov
681cdb023e fix form validation mode 2025-01-24 14:49:12 +03:00
Ildar Kamalov
254b25a026 cleanup forms 2025-01-21 16:52:12 +03:00
Ildar Kamalov
70aeee3037 cleanup forms 2025-01-21 16:14:20 +03:00
Ildar Kamalov
edd9bf7b59 cleanup forms 2025-01-21 15:31:08 +03:00
Ildar Kamalov
290987d020 fix client form 2025-01-21 14:27:05 +03:00
Ildar Kamalov
28cd8a41d2 add clients forms 2025-01-21 13:47:40 +03:00
Ildar Kamalov
efd907216f add DHCP form 2025-01-20 10:54:13 +03:00
Ildar Kamalov
93890c2c6f add encryption form and common components 2025-01-18 19:21:20 +03:00
Ildar Kamalov
b4aa411826 fix checkbox field 2025-01-17 16:54:55 +03:00
Ildar Kamalov
bcf5fb2521 use common checkbox component 2025-01-17 16:36:39 +03:00
Ildar Kamalov
92c004d15d filters form 2025-01-15 16:42:26 +03:00
Ildar Kamalov
c389d88aae add filters config form 2025-01-15 15:20:16 +03:00
Ildar Kamalov
657c047784 fix install and devices 2025-01-15 12:19:25 +03:00
Ildar Kamalov
87fe4dfb7f static lease form 2025-01-15 11:53:26 +03:00
Ildar Kamalov
ddc1f73554 logs filter form 2025-01-14 18:45:29 +03:00
Ildar Kamalov
aeab662335 blocked services form 2025-01-14 16:20:51 +03:00
Ildar Kamalov
8b40fe97a6 stats config form 2025-01-14 15:17:00 +03:00
Ildar Kamalov
d0dc0c600d logs config form 2025-01-14 14:11:38 +03:00
Ildar Kamalov
61cba0e212 add mobile config form 2025-01-13 16:57:12 +03:00
dmitrii
b538096ab0 update components to use react-hook-form 2024-12-24 14:37:52 +03:00
dmitrii
914affe0c0 update components to use react-hook-form 2024-12-24 13:55:54 +03:00
dmitrii
da808bfcbc move login to react-hook-form 2024-12-19 12:43:35 +03:00
Ildar Kamalov
8e2dea267c rewrite form 2024-12-12 17:01:30 +03:00
Ildar Kamalov
77420d8c96 filter check form 2024-12-12 15:21:57 +03:00
Ildar Kamalov
81f66c5b9f fix import 2024-12-12 15:09:28 +03:00
Ildar Kamalov
0a1739df3b install forms 2024-12-12 15:08:42 +03:00
Igor Lobanov
8e43af21d9 first playwrite test 2024-12-11 13:10:47 +01:00
102 changed files with 7075 additions and 10256 deletions

View File

@@ -2,7 +2,7 @@
'env': 'env':
'GO_VERSION': '1.23.6' 'GO_VERSION': '1.23.6'
'NODE_VERSION': '16' 'NODE_VERSION': '18'
'on': 'on':
'push': 'push':

4
.gitignore vendored
View File

@@ -19,6 +19,10 @@
/agh-backup/ /agh-backup/
/bin/ /bin/
/build/* /build/*
/client/blob-report/
/client/playwright-report/
/client/playwright/.cache/
/client/test-results/
/data/ /data/
/dist/ /dist/
/filtering/tests/filtering.TestLotsOfRules*.pprof /filtering/tests/filtering.TestLotsOfRules*.pprof

View File

@@ -32,8 +32,7 @@ GPG_KEY = devteam@adguard.com
GPG_KEY_PASSPHRASE = not-a-real-password GPG_KEY_PASSPHRASE = not-a-real-password
NPM = npm NPM = npm
NPM_FLAGS = --prefix $(CLIENT_DIR) NPM_FLAGS = --prefix $(CLIENT_DIR)
NPM_INSTALL_FLAGS = $(NPM_FLAGS) --quiet --no-progress --ignore-engines\ NPM_INSTALL_FLAGS = $(NPM_FLAGS) --quiet --no-progress
--ignore-optional --ignore-platform --ignore-scripts
RACE = 0 RACE = 0
REVISION = $${REVISION:-$$(git rev-parse --short HEAD)} REVISION = $${REVISION:-$$(git rev-parse --short HEAD)}
SIGN = 1 SIGN = 1
@@ -105,10 +104,12 @@ build-docker: ; $(ENV) "$(SHELL)" ./scripts/make/build-docker.sh
build-release: $(BUILD_RELEASE_DEPS_$(FRONTEND_PREBUILT)) build-release: $(BUILD_RELEASE_DEPS_$(FRONTEND_PREBUILT))
$(ENV) "$(SHELL)" ./scripts/make/build-release.sh $(ENV) "$(SHELL)" ./scripts/make/build-release.sh
js-build: ; $(NPM) $(NPM_FLAGS) run build-prod js-build: ; $(NPM) $(NPM_FLAGS) run build-prod
js-deps: ; $(NPM) $(NPM_INSTALL_FLAGS) ci js-deps: ; $(NPM) $(NPM_INSTALL_FLAGS) ci
js-lint: ; $(NPM) $(NPM_FLAGS) run lint js-typecheck: ; $(NPM) $(NPM_FLAGS) run typecheck
js-test: ; $(NPM) $(NPM_FLAGS) run test js-lint: ; $(NPM) $(NPM_FLAGS) run lint
js-test: ; $(NPM) $(NPM_FLAGS) run test
js-test-e2e: ; $(NPM) $(NPM_FLAGS) run test:e2e
go-bench: ; $(ENV) "$(SHELL)" ./scripts/make/go-bench.sh go-bench: ; $(ENV) "$(SHELL)" ./scripts/make/go-bench.sh
go-build: ; $(ENV) "$(SHELL)" ./scripts/make/go-build.sh go-build: ; $(ENV) "$(SHELL)" ./scripts/make/go-build.sh

View File

@@ -290,6 +290,22 @@ When you need to debug the frontend without recompiling the production version e
[targ-docker]: https://github.com/AdguardTeam/AdGuardHome/tree/master/scripts#build-dockersh-build-a-multi-architecture-docker-image [targ-docker]: https://github.com/AdguardTeam/AdGuardHome/tree/master/scripts#build-dockersh-build-a-multi-architecture-docker-image
[targ-release]: https://github.com/AdguardTeam/AdGuardHome/tree/master/scripts#build-releasesh-build-a-release-for-all-platforms [targ-release]: https://github.com/AdguardTeam/AdGuardHome/tree/master/scripts#build-releasesh-build-a-release-for-all-platforms
#### <a href="#e2e-frontend-tests" id="e2e-frontend-tests" name="e2e-frontend-tests">End-to-End (E2E) Frontend Tests</a>
AdGuard Home uses [Playwright](https://playwright.dev) for E2E testing. Tests are located in `tests/e2e`.
**Running Tests:**
- `npm run test:e2e` run all tests (headless).
- `npm run test:e2e:interactive` run tests interactively.
- `npm run test:e2e:debug` run tests in debug mode.
- `npm run test:e2e:codegen` generate new test code.
**Setup:**
1. Run `npm install` to install dependencies.
2. Run `npx playwright install` to set up required browsers.
> **Warning:** Playwright will download and install its own browser binaries for testing, which may differ from the browsers installed on your system.
## <a href="#contributing" id="contributing" name="contributing">Contributing</a> ## <a href="#contributing" id="contributing" name="contributing">Contributing</a>
You are welcome to fork this repository, make your changes and [submit a pull request][pr]. Please make sure you follow our [code guidelines][guide] though. You are welcome to fork this repository, make your changes and [submit a pull request][pr]. Please make sure you follow our [code guidelines][guide] though.

View File

@@ -7,7 +7,7 @@
# Make sure to sync any changes with the branch overrides below. # Make sure to sync any changes with the branch overrides below.
'variables': 'variables':
'channel': 'edge' 'channel': 'edge'
'dockerFrontend': 'adguard/home-js-builder:2.0' 'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.23.6--1' 'dockerGo': 'adguard/go-builder:1.23.6--1'
'stages': 'stages':
@@ -277,7 +277,7 @@
# need to build a few of these. # need to build a few of these.
'variables': 'variables':
'channel': 'beta' 'channel': 'beta'
'dockerFrontend': 'adguard/home-js-builder:2.0' 'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.23.6--1' 'dockerGo': 'adguard/go-builder:1.23.6--1'
# release-vX.Y.Z branches are the branches from which the actual final # release-vX.Y.Z branches are the branches from which the actual final
# release is built. # release is built.
@@ -293,5 +293,5 @@
# are the ones that actually get released. # are the ones that actually get released.
'variables': 'variables':
'channel': 'release' 'channel': 'release'
'dockerFrontend': 'adguard/home-js-builder:2.0' 'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.23.6--1' 'dockerGo': 'adguard/go-builder:1.23.6--1'

View File

@@ -5,7 +5,7 @@
'key': 'AHBRTSPECS' 'key': 'AHBRTSPECS'
'name': 'AdGuard Home - Build and run tests' 'name': 'AdGuard Home - Build and run tests'
'variables': 'variables':
'dockerFrontend': 'adguard/home-js-builder:2.0' 'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.23.6--1' 'dockerGo': 'adguard/go-builder:1.23.6--1'
'channel': 'development' 'channel': 'development'
@@ -29,6 +29,12 @@
jobs: jobs:
- 'Artifact' - 'Artifact'
- 'E2E':
manual: false
final: false
jobs:
- 'Test e2e'
'Test frontend': 'Test frontend':
'docker': 'docker':
'image': '${bamboo.dockerFrontend}' 'image': '${bamboo.dockerFrontend}'
@@ -48,7 +54,7 @@
set -e -f -u -x set -e -f -u -x
make VERBOSE=1 js-deps js-lint js-test make VERBOSE=1 js-deps js-typecheck js-lint js-test
'final-tasks': 'final-tasks':
- 'clean' - 'clean'
'requirements': 'requirements':
@@ -165,6 +171,38 @@
'requirements': 'requirements':
- 'adg-docker': 'true' - 'adg-docker': 'true'
'Test e2e':
'artifact-subscriptions':
- 'artifact': 'AdGuardHome_linux_amd64'
- 'artifact': 'AdGuardHome frontend'
'docker':
'image': '${bamboo.dockerFrontend}'
'volumes':
'${system.YARN_DIR}': '${bamboo.cacheYarn}'
'key': 'E2ETEST'
'other':
'clean-working-dir': true
'tasks':
- 'checkout':
'force-clean-build': true
- 'script':
'interpreter': 'SHELL'
'scripts':
- |
#!/bin/sh
set -e -f -u -x
export CI=true
tar -xzf dist/AdGuardHome_linux_amd64.tar.gz -C /tmp
mv /tmp/AdGuardHome/AdGuardHome ./AdGuardHome
make VERBOSE=1 js-deps js-test-e2e
'requirements':
- 'adg-docker': 'true'
'branches': 'branches':
'create': 'for-pull-request' 'create': 'for-pull-request'
'delete': 'delete':
@@ -195,6 +233,6 @@
# Set the default release channel on the release branch to beta, as we # Set the default release channel on the release branch to beta, as we
# may need to build a few of these. # may need to build a few of these.
'variables': 'variables':
'dockerFrontend': 'adguard/home-js-builder:2.0' 'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.23.6--1' 'dockerGo': 'adguard/go-builder:1.23.6--1'
'channel': 'candidate' 'channel': 'candidate'

22
client/.eslintrc.json vendored
View File

@@ -1,5 +1,7 @@
{ {
"plugins": ["prettier"], "plugins": [
"prettier"
],
"extends": [ "extends": [
"airbnb-base", "airbnb-base",
"prettier", "prettier",
@@ -21,12 +23,23 @@
}, },
"import/resolver": { "import/resolver": {
"node": { "node": {
"extensions": [".js", ".jsx", ".ts", ".tsx"] "extensions": [
".js",
".jsx",
".ts",
".tsx"
]
} }
} }
}, },
"rules": { "rules": {
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"import/extensions": [ "import/extensions": [
"error", "error",
"ignorePackages", "ignorePackages",
@@ -43,7 +56,10 @@
"no-console": [ "no-console": [
"warn", "warn",
{ {
"allow": ["warn", "error"] "allow": [
"warn",
"error"
]
} }
], ],
"import/no-extraneous-dependencies": [ "import/no-extraneous-dependencies": [

View File

@@ -1,6 +0,0 @@
export default {
testEnvironment: 'jsdom',
transform: {
'^.+\\.tsx?$': 'babel-jest',
},
};

8728
client/package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

23
client/package.json vendored
View File

@@ -7,11 +7,14 @@
"build-prod": "cross-env BUILD_ENV=prod webpack --config webpack.prod.js", "build-prod": "cross-env BUILD_ENV=prod webpack --config webpack.prod.js",
"watch": "cross-env BUILD_ENV=dev webpack --config webpack.dev.js --watch", "watch": "cross-env BUILD_ENV=dev webpack --config webpack.dev.js --watch",
"watch:hot": "cross-env BUILD_ENV=dev webpack-dev-server --config webpack.dev.js", "watch:hot": "cross-env BUILD_ENV=dev webpack-dev-server --config webpack.dev.js",
"lint": "echo 'Lint temporarily disabled'", "lint": "eslint --ext .ts,.tsx src",
"lint-new": "eslint './src/**/*.(ts|tsx)'", "lint:fix": "eslint --ext .ts,.tsx src --fix",
"lint:fix": "eslint './src/**/*.(ts|tsx)' --fix", "test": "vitest --run",
"test": "jest", "test:watch": "vitest --watch",
"test:watch": "jest --watch", "test:e2e": "npx playwright test tests/e2e",
"test:e2e:interactive": "npx playwright test --ui",
"test:e2e:debug": "npx playwright test --debug",
"test:e2e:codegen": "npx playwright codegen",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch" "typecheck:watch": "tsc --noEmit --watch"
}, },
@@ -20,6 +23,7 @@
"@nivo/line": "^0.64.0", "@nivo/line": "^0.64.0",
"axios": "^0.19.2", "axios": "^0.19.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clsx": "^2.1.1",
"countries-and-timezones": "^3.6.0", "countries-and-timezones": "^3.6.0",
"date-fns": "^1.29.0", "date-fns": "^1.29.0",
"i18next": "^19.6.2", "i18next": "^19.6.2",
@@ -34,6 +38,7 @@
"react": "^16.13.1", "react": "^16.13.1",
"react-click-outside": "^3.0.1", "react-click-outside": "^3.0.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-hook-form": "^7.54.0",
"react-i18next": "^11.7.2", "react-i18next": "^11.7.2",
"react-modal": "^3.11.2", "react-modal": "^3.11.2",
"react-popper-tooltip": "^2.11.1", "react-popper-tooltip": "^2.11.1",
@@ -46,7 +51,6 @@
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-actions": "^2.6.5", "redux-actions": "^2.6.5",
"redux-form": "^8.3.10",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"ts-migrate": "^0.1.35", "ts-migrate": "^0.1.35",
"url-polyfill": "^1.1.12" "url-polyfill": "^1.1.12"
@@ -60,15 +64,15 @@
"@babel/plugin-transform-runtime": "^7.24.3", "@babel/plugin-transform-runtime": "^7.24.3",
"@babel/preset-env": "^7.24.5", "@babel/preset-env": "^7.24.5",
"@babel/preset-react": "^7.24.1", "@babel/preset-react": "^7.24.1",
"@types/jest": "^29.5.12", "@playwright/test": "1.50.1",
"@types/lodash": "^4.17.4", "@types/lodash": "^4.17.4",
"@types/node": "^22.10.2",
"@types/react": "^17.0.80", "@types/react": "^17.0.80",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-redux": "^7.1.33", "@types/react-redux": "^7.1.33",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.20", "@types/react-table": "^7.7.20",
"@types/redux-actions": "^2.6.5", "@types/redux-actions": "^2.6.5",
"@types/redux-form": "^8.3.10",
"@typescript-eslint/eslint-plugin": "^7.11.0", "@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.10.0", "@typescript-eslint/parser": "^7.10.0",
"babel-loader": "^9.1.3", "babel-loader": "^9.1.3",
@@ -85,8 +89,6 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jscodeshift": "^0.15.2", "jscodeshift": "^0.15.2",
"mini-css-extract-plugin": "^2.9.0", "mini-css-extract-plugin": "^2.9.0",
"path": "^0.12.7", "path": "^0.12.7",
@@ -97,6 +99,7 @@
"stylelint": "^16.5.0", "stylelint": "^16.5.0",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"vitest": "^3.0.4",
"webpack": "^5.91.0", "webpack": "^5.91.0",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4", "webpack-dev-server": "^5.0.4",

52
client/playwright.config.ts vendored Normal file
View File

@@ -0,0 +1,52 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
import { CONFIG_FILE_PATH } from './tests/constants';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/e2e',
globalSetup: path.resolve('./tests/e2e/globalSetup.ts'),
globalTeardown: path.resolve('./tests/e2e/globalTeardown.ts'),
timeout: 5000,
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
launchOptions: {
headless: true,
},
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
stdout: process.env.CI ? 'pipe' : 'ignore',
command: `${!process.env.CI ? 'sudo ' : ''}./AdGuardHome --local-frontend -v -c ${CONFIG_FILE_PATH}`,
url: 'http://127.0.0.1:3000',
cwd: '..',
reuseExistingServer: !process.env.CI,
timeout: 10000,
},
});

View File

@@ -1,3 +1,5 @@
import { describe, expect, test, afterEach, vi, beforeEach, it } from 'vitest';
import { sortIp, countClientsStatistics, findAddressType, subnetMaskToBitMask } from '../helpers/helpers'; import { sortIp, countClientsStatistics, findAddressType, subnetMaskToBitMask } from '../helpers/helpers';
import { ADDRESS_TYPES } from '../helpers/constants'; import { ADDRESS_TYPES } from '../helpers/constants';
@@ -259,7 +261,7 @@ describe('sortIp', () => {
const originalWarn = console.warn; const originalWarn = console.warn;
beforeEach(() => { beforeEach(() => {
console.warn = jest.fn(); console.warn = vi.fn();
}); });
afterEach(() => { afterEach(() => {
@@ -347,15 +349,15 @@ describe('sortIp', () => {
}); });
describe('findAddressType', () => { describe('findAddressType', () => {
describe('ip', () => { it('should return IP type for IP addresses', () => {
expect(findAddressType('127.0.0.1')).toStrictEqual(ADDRESS_TYPES.IP); expect(findAddressType('127.0.0.1')).toStrictEqual(ADDRESS_TYPES.IP);
}); });
describe('cidr', () => { it('should return CIDR type for CIDR addresses', () => {
expect(findAddressType('127.0.0.1/8')).toStrictEqual(ADDRESS_TYPES.CIDR); expect(findAddressType('127.0.0.1/8')).toStrictEqual(ADDRESS_TYPES.CIDR);
}); });
describe('mac', () => { it('should return UNKNOWN type for MAC addresses', () => {
expect(findAddressType('00:1B:44:11:3A:B7')).toStrictEqual(ADDRESS_TYPES.UNKNOWN); expect(findAddressType('00:1B:44:11:3A:B7')).toStrictEqual(ADDRESS_TYPES.UNKNOWN);
}); });
}); });

View File

@@ -19,7 +19,6 @@ import {
CHECK_TIMEOUT, CHECK_TIMEOUT,
STATUS_RESPONSE, STATUS_RESPONSE,
SETTINGS_NAMES, SETTINGS_NAMES,
FORM_NAME,
MANUAL_UPDATE_LINK, MANUAL_UPDATE_LINK,
DISABLE_PROTECTION_TIMINGS, DISABLE_PROTECTION_TIMINGS,
} from '../helpers/constants'; } from '../helpers/constants';
@@ -424,10 +423,9 @@ export const testUpstream =
} }
}; };
export const testUpstreamWithFormValues = () => async (dispatch: any, getState: any) => { export const testUpstreamWithFormValues = (formValues: any) => async (dispatch: any, getState: any) => {
const { upstream_dns_file } = getState().dnsConfig; const { upstream_dns_file } = getState().dnsConfig;
const { bootstrap_dns, upstream_dns, local_ptr_upstreams, fallback_dns } = const { bootstrap_dns, upstream_dns, local_ptr_upstreams, fallback_dns } = formValues;
getState().form[FORM_NAME.UPSTREAM].values;
return dispatch( return dispatch(
testUpstream( testUpstream(
@@ -512,16 +510,15 @@ export const findActiveDhcpRequest = createAction('FIND_ACTIVE_DHCP_REQUEST');
export const findActiveDhcpSuccess = createAction('FIND_ACTIVE_DHCP_SUCCESS'); export const findActiveDhcpSuccess = createAction('FIND_ACTIVE_DHCP_SUCCESS');
export const findActiveDhcpFailure = createAction('FIND_ACTIVE_DHCP_FAILURE'); export const findActiveDhcpFailure = createAction('FIND_ACTIVE_DHCP_FAILURE');
export const findActiveDhcp = (name: any) => async (dispatch: any, getState: any) => { export const findActiveDhcp = (selectedInterface: any) => async (dispatch: any, getState: any) => {
dispatch(findActiveDhcpRequest()); dispatch(findActiveDhcpRequest());
try { try {
const req = { const req = {
interface: name, interface: selectedInterface,
}; };
const activeDhcp = await apiClient.findActiveDhcp(req); const activeDhcp = await apiClient.findActiveDhcp(req);
dispatch(findActiveDhcpSuccess(activeDhcp)); dispatch(findActiveDhcpSuccess(activeDhcp));
const { check, interface_name, interfaces } = getState().dhcp; const { check, interface_name, interfaces } = getState().dhcp;
const selectedInterface = getState().form[FORM_NAME.DHCP_INTERFACES].values.interface_name;
const v4 = check?.v4 ?? { static_ip: {}, other_server: {} }; const v4 = check?.v4 ?? { static_ip: {}, other_server: {} };
const v6 = check?.v6 ?? { other_server: {} }; const v6 = check?.v6 ?? { other_server: {} };

View File

@@ -27,7 +27,8 @@ export const setAllSettingsSuccess = createAction('SET_ALL_SETTINGS_SUCCESS');
export const setAllSettings = (values: any) => async (dispatch: any) => { export const setAllSettings = (values: any) => async (dispatch: any) => {
dispatch(setAllSettingsRequest()); dispatch(setAllSettingsRequest());
try { try {
const { confirm_password, ...config } = values; const config = { ...values };
delete config.confirm_password;
await apiClient.setAllSettings(config); await apiClient.setAllSettings(config);
dispatch(setAllSettingsSuccess()); dispatch(setAllSettingsSuccess());
@@ -48,7 +49,11 @@ export const checkConfig = (values: any) => async (dispatch: any) => {
dispatch(checkConfigRequest()); dispatch(checkConfigRequest());
try { try {
const check = await apiClient.checkConfig(values); const check = await apiClient.checkConfig(values);
dispatch(checkConfigSuccess(check)); dispatch(checkConfigSuccess({
web: { ...values.web, ...check.web },
dns: { ...values.dns, ...check.dns },
static_ip: check.static_ip,
}));
} catch (error) { } catch (error) {
dispatch(addErrorToast({ error })); dispatch(addErrorToast({ error }));
dispatch(checkConfigFailure()); dispatch(checkConfigFailure());

View File

@@ -3,8 +3,9 @@ import { createAction } from 'redux-actions';
import apiClient from '../api/Api'; import apiClient from '../api/Api';
import { normalizeLogs } from '../helpers/helpers'; import { normalizeLogs } from '../helpers/helpers';
import { DEFAULT_LOGS_FILTER, FORM_NAME, QUERY_LOGS_PAGE_LIMIT } from '../helpers/constants'; import { DEFAULT_LOGS_FILTER, QUERY_LOGS_PAGE_LIMIT } from '../helpers/constants';
import { addErrorToast, addSuccessToast } from './toasts'; import { addErrorToast, addSuccessToast } from './toasts';
import { SearchFormValues } from '../components/Logs';
const getLogsWithParams = async (config: any) => { const getLogsWithParams = async (config: any) => {
const { older_than, filter, ...values } = config; const { older_than, filter, ...values } = config;
@@ -27,12 +28,10 @@ export const getAdditionalLogsRequest = createAction('GET_ADDITIONAL_LOGS_REQUES
export const getAdditionalLogsFailure = createAction('GET_ADDITIONAL_LOGS_FAILURE'); export const getAdditionalLogsFailure = createAction('GET_ADDITIONAL_LOGS_FAILURE');
export const getAdditionalLogsSuccess = createAction('GET_ADDITIONAL_LOGS_SUCCESS'); export const getAdditionalLogsSuccess = createAction('GET_ADDITIONAL_LOGS_SUCCESS');
const shortPollQueryLogs = async (data: any, filter: any, dispatch: any, getState: any, total?: any) => { const shortPollQueryLogs = async (data: any, filter: any, dispatch: any, currentQuery?: string, total?: any) => {
const { logs, oldest } = data; const { logs, oldest } = data;
const totalData = total || { logs }; const totalData = total || { logs };
const queryForm = getState().form[FORM_NAME.LOGS_FILTER];
const currentQuery = queryForm && queryForm.values.search;
const previousQuery = filter?.search; const previousQuery = filter?.search;
const isQueryTheSame = const isQueryTheSame =
typeof previousQuery === 'string' && typeof currentQuery === 'string' && previousQuery === currentQuery; typeof previousQuery === 'string' && typeof currentQuery === 'string' && previousQuery === currentQuery;
@@ -51,7 +50,7 @@ const shortPollQueryLogs = async (data: any, filter: any, dispatch: any, getStat
filter, filter,
}); });
if (additionalLogs.oldest.length > 0) { if (additionalLogs.oldest.length > 0) {
return await shortPollQueryLogs(additionalLogs, filter, dispatch, getState, { return await shortPollQueryLogs(additionalLogs, filter, dispatch, currentQuery, {
logs: [...totalData.logs, ...additionalLogs.logs], logs: [...totalData.logs, ...additionalLogs.logs],
oldest: additionalLogs.oldest, oldest: additionalLogs.oldest,
}); });
@@ -91,17 +90,18 @@ export const updateLogs = () => async (dispatch: any, getState: any) => {
} }
}; };
export const getLogs = () => async (dispatch: any, getState: any) => { export const getLogs = (currentQuery?: string) => async (dispatch: any, getState: any) => {
dispatch(getLogsRequest()); dispatch(getLogsRequest());
try { try {
const { isFiltered, filter, oldest } = getState().queryLogs; const { isFiltered, filter, oldest } = getState().queryLogs;
const data = await getLogsWithParams({ const data = await getLogsWithParams({
older_than: oldest, older_than: oldest,
filter, filter,
}); });
if (isFiltered) { if (isFiltered) {
const additionalData = await shortPollQueryLogs(data, filter, dispatch, getState); const additionalData = await shortPollQueryLogs(data, filter, dispatch, currentQuery);
const updatedData = additionalData.logs ? { ...data, ...additionalData } : data; const updatedData = additionalData.logs ? { ...data, ...additionalData } : data;
dispatch(getLogsSuccess(updatedData)); dispatch(getLogsSuccess(updatedData));
} else { } else {
@@ -122,13 +122,13 @@ export const setLogsFilterRequest = createAction('SET_LOGS_FILTER_REQUEST');
* @param {string} filter.response_status 'QUERY' field of RESPONSE_FILTER object * @param {string} filter.response_status 'QUERY' field of RESPONSE_FILTER object
* @returns function * @returns function
*/ */
export const setLogsFilter = (filter: any) => setLogsFilterRequest(filter); export const setLogsFilter = (filter: SearchFormValues) => setLogsFilterRequest(filter);
export const setFilteredLogsRequest = createAction('SET_FILTERED_LOGS_REQUEST'); export const setFilteredLogsRequest = createAction('SET_FILTERED_LOGS_REQUEST');
export const setFilteredLogsFailure = createAction('SET_FILTERED_LOGS_FAILURE'); export const setFilteredLogsFailure = createAction('SET_FILTERED_LOGS_FAILURE');
export const setFilteredLogsSuccess = createAction('SET_FILTERED_LOGS_SUCCESS'); export const setFilteredLogsSuccess = createAction('SET_FILTERED_LOGS_SUCCESS');
export const setFilteredLogs = (filter?: any) => async (dispatch: any, getState: any) => { export const setFilteredLogs = (filter?: SearchFormValues) => async (dispatch: any) => {
dispatch(setFilteredLogsRequest()); dispatch(setFilteredLogsRequest());
try { try {
const data = await getLogsWithParams({ const data = await getLogsWithParams({
@@ -136,7 +136,9 @@ export const setFilteredLogs = (filter?: any) => async (dispatch: any, getState:
filter, filter,
}); });
const additionalData = await shortPollQueryLogs(data, filter, dispatch, getState); const currentQuery = filter?.search;
const additionalData = await shortPollQueryLogs(data, filter, dispatch, currentQuery);
const updatedData = additionalData.logs ? { ...data, ...additionalData } : data; const updatedData = additionalData.logs ? { ...data, ...additionalData } : data;
dispatch( dispatch(

View File

@@ -1,62 +1,74 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Field, reduxForm } from 'redux-form';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Controller, useForm } from 'react-hook-form';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import { renderInputField } from '../../../helpers/form';
import Info from './Info'; import Info from './Info';
import { FORM_NAME } from '../../../helpers/constants';
import { RootState } from '../../../initialState';
interface CheckProps { import { RootState } from '../../../initialState';
handleSubmit: (...args: unknown[]) => string; import { validateRequiredValue } from '../../../helpers/validators';
pristine: boolean; import { Input } from '../../ui/Controls/Input';
invalid: boolean;
interface FormValues {
name: string;
} }
const Check = (props: CheckProps) => { type Props = {
const { pristine, invalid, handleSubmit } = props; onSubmit?: (data: FormValues) => void;
};
const Check = ({ onSubmit }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const processingCheck = useSelector((state: RootState) => state.filtering.processingCheck); const processingCheck = useSelector((state: RootState) => state.filtering.processingCheck);
const hostname = useSelector((state: RootState) => state.filtering.check.hostname); const hostname = useSelector((state: RootState) => state.filtering.check.hostname);
const {
control,
handleSubmit,
formState: { isDirty, isValid },
} = useForm<FormValues>({
mode: 'onBlur',
defaultValues: {
name: '',
},
});
return ( return (
<Card title={t('check_title')} subtitle={t('check_desc')}> <Card title={t('check_title')} subtitle={t('check_desc')}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="row"> <div className="row">
<div className="col-12 col-md-6"> <div className="col-12 col-md-6">
<div className="input-group"> <Controller
<Field name="name"
id="name" control={control}
name="name" rules={{ validate: validateRequiredValue }}
component={renderInputField} render={({ field, fieldState }) => (
type="text" <Input
className="form-control" {...field}
placeholder={t('form_enter_host')} type="text"
/> data-testid="check_domain_name"
placeholder={t('form_enter_host')}
<span className="input-group-append"> error={fieldState.error?.message}
<button rightAddon={
className="btn btn-success btn-standard btn-large" <span className="input-group-append">
type="submit" <button
onClick={handleSubmit} className="btn btn-success btn-standard btn-large"
disabled={pristine || invalid || processingCheck}> type="submit"
{t('check')} data-testid="check_domain_submit"
</button> disabled={!isDirty || !isValid || processingCheck}>
</span> {t('check')}
</div> </button>
</span>
}
/>
)}
/>
{hostname && ( {hostname && (
<> <>
<hr /> <hr />
<Info /> <Info />
</> </>
)} )}
@@ -67,4 +79,4 @@ const Check = (props: CheckProps) => {
); );
}; };
export default reduxForm({ form: FORM_NAME.DOMAIN_CHECK })(Check); export default Check;

View File

@@ -0,0 +1,94 @@
import React from 'react';
import classNames from 'classnames';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Checkbox } from '../ui/Controls/Checkbox';
const getIconsData = (homepage: string, source: string) => [
{
iconName: 'dashboard',
href: homepage,
className: 'ml-1',
},
{
iconName: 'info',
href: source,
},
];
const renderIcons = (iconsData: { iconName: string; href: string; className?: string }[]) =>
iconsData.map(({ iconName, href, className = '' }) => (
<a
key={iconName}
href={href}
target="_blank"
rel="noopener noreferrer"
className={classNames('d-flex align-items-center', className)}>
<svg className="icon icon--15 mr-1 icon--gray">
<use xlinkHref={`#${iconName}`} />
</svg>
</a>
));
type Filter = {
categoryId: string;
homepage: string;
source: string;
name: string;
};
type Category = {
name: string;
description: string;
};
type Props = {
categories: Record<string, Category>;
filters: Record<string, Filter>;
selectedSources: Record<string, boolean>;
};
export const FiltersList = ({ categories, filters, selectedSources }: Props) => {
const { t } = useTranslation();
const { control } = useFormContext();
return (
<>
{Object.entries(categories).map(([categoryId, category]) => {
const categoryFilters = Object.entries(filters)
.filter(([, filter]) => filter.categoryId === categoryId)
.map(([key, filter]) => ({ ...filter, id: key }));
return (
<div key={category.name} className="modal-body__item">
<h6 className="font-weight-bold mb-1">{t(category.name)}</h6>
<p className="mb-3">{t(category.description)}</p>
{categoryFilters.map((filter) => {
const { homepage, source, name, id } = filter;
const isSelected = selectedSources[source];
const iconsData = getIconsData(homepage, source);
return (
<div key={name} className="d-flex align-items-center pb-1">
<Controller
name={id}
control={control}
render={({ field }) => (
<Checkbox
{...field}
data-testid={`filters_${id}`}
title={name}
disabled={isSelected}
/>
)}
/>
{renderIcons(iconsData)}
</div>
);
})}
</div>
);
})}
</>
);
};

View File

@@ -1,208 +1,152 @@
import React from 'react'; import React from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { Field, reduxForm } from 'redux-form'; import { useTranslation } from 'react-i18next';
import { withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import classNames from 'classnames';
import { validatePath, validateRequiredValue } from '../../helpers/validators'; import { validatePath, validateRequiredValue } from '../../helpers/validators';
import { CheckboxField, renderInputField } from '../../helpers/form'; import { MODAL_OPEN_TIMEOUT, MODAL_TYPE } from '../../helpers/constants';
import { MODAL_OPEN_TIMEOUT, MODAL_TYPE, FORM_NAME } from '../../helpers/constants';
import filtersCatalog from '../../helpers/filters/filters'; import filtersCatalog from '../../helpers/filters/filters';
import { FiltersList } from './FiltersList';
import { Input } from '../ui/Controls/Input';
const getIconsData = (homepage: any, source: any) => [ type FormValues = {
{ enabled: boolean;
iconName: 'dashboard', name: string;
href: homepage, url: string;
className: 'ml-1', };
},
{
iconName: 'info',
href: source,
},
];
const renderIcons = (iconsData: any) => const defaultValues: FormValues = {
iconsData.map(({ iconName, href, className = '' }: any) => ( enabled: true,
<a name: '',
key={iconName} url: '',
href={href} };
target="_blank"
rel="noopener noreferrer"
className={classNames('d-flex align-items-center', className)}>
<svg className="icon icon--15 mr-1 icon--gray">
<use xlinkHref={`#${iconName}`} />
</svg>
</a>
));
interface renderCheckboxFieldProps { type Props = {
// https://redux-form.com/8.3.0/docs/api/field.md/#props closeModal: () => void;
input: { onSubmit: (values: FormValues) => void;
name: string;
value: string;
checked: boolean;
onChange: (...args: unknown[]) => unknown;
};
disabled: boolean;
}
const renderCheckboxField = (props: renderCheckboxFieldProps) => (
<CheckboxField
{...props}
meta={{ touched: false, error: null }}
input={{
...props.input,
checked: props.disabled || props.input.checked,
}}
/>
);
const renderFilters = ({ categories, filters }: any, selectedSources: any, t: any) =>
Object.keys(categories).map((categoryId) => {
const category = categories[categoryId];
const categoryFilters: any = [];
Object.keys(filters)
.sort()
.forEach((key) => {
const filter = filters[key];
filter.id = key;
if (filter.categoryId === categoryId) {
categoryFilters.push(filter);
}
});
return (
<div key={category.name} className="modal-body__item">
<h6 className="font-weight-bold mb-1">{t(category.name)}</h6>
<p className="mb-3">{t(category.description)}</p>
{categoryFilters.map((filter) => {
const { homepage, source, name } = filter;
const isSelected = Object.prototype.hasOwnProperty.call(selectedSources, source);
const iconsData = getIconsData(homepage, source);
return (
<div key={name} className="d-flex align-items-center pb-1">
<Field
name={filter.id}
type="checkbox"
component={renderCheckboxField}
placeholder={t(name)}
disabled={isSelected}
/>
{renderIcons(iconsData)}
</div>
);
})}
</div>
);
});
interface FormProps {
t: (...args: unknown[]) => string;
closeModal: (...args: unknown[]) => unknown;
handleSubmit: (...args: unknown[]) => string;
processingAddFilter: boolean; processingAddFilter: boolean;
processingConfigFilter: boolean; processingConfigFilter: boolean;
whitelist?: boolean; whitelist?: boolean;
modalType: string; modalType: string;
toggleFilteringModal: (...args: unknown[]) => unknown; toggleFilteringModal: ({ type }: { type?: keyof typeof MODAL_TYPE }) => void;
selectedSources?: object; selectedSources?: Record<string, boolean>;
} initialValues?: FormValues;
};
const Form = (props: FormProps) => { export const Form = ({
const { closeModal,
t, processingAddFilter,
closeModal, processingConfigFilter,
handleSubmit, whitelist,
processingAddFilter, modalType,
processingConfigFilter, toggleFilteringModal,
whitelist, selectedSources,
modalType, onSubmit,
toggleFilteringModal, initialValues,
selectedSources, }: Props) => {
} = props; const { t } = useTranslation();
const openModal = (modalType: any, timeout = MODAL_OPEN_TIMEOUT) => { const methods = useForm({
toggleFilteringModal(); defaultValues: {
...defaultValues,
...initialValues,
},
mode: 'onBlur',
});
const { handleSubmit, control } = methods;
const openModal = (modalType: keyof typeof MODAL_TYPE, timeout = MODAL_OPEN_TIMEOUT) => {
toggleFilteringModal(undefined);
setTimeout(() => toggleFilteringModal({ type: modalType }), timeout); setTimeout(() => toggleFilteringModal({ type: modalType }), timeout);
}; };
const openFilteringListModal = () => openModal(MODAL_TYPE.CHOOSE_FILTERING_LIST); const openFilteringListModal = () => openModal('CHOOSE_FILTERING_LIST');
const openAddFiltersModal = () => openModal(MODAL_TYPE.ADD_FILTERS); const openAddFiltersModal = () => openModal('ADD_FILTERS');
return ( return (
<form onSubmit={handleSubmit}> <FormProvider {...methods}>
<div className="modal-body modal-body--filters"> <form onSubmit={handleSubmit(onSubmit)}>
{modalType === MODAL_TYPE.SELECT_MODAL_TYPE && ( <div className="modal-body modal-body--filters">
<div className="d-flex justify-content-around"> {modalType === MODAL_TYPE.SELECT_MODAL_TYPE && (
<button <div className="d-flex justify-content-around">
onClick={openFilteringListModal} <button
className="btn btn-success btn-standard mr-2 btn-large"> onClick={openFilteringListModal}
{t('choose_from_list')} className="btn btn-success btn-standard mr-2 btn-large">
</button> {t('choose_from_list')}
</button>
<button onClick={openAddFiltersModal} className="btn btn-primary btn-standard"> <button onClick={openAddFiltersModal} className="btn btn-primary btn-standard">
{t('add_custom_list')} {t('add_custom_list')}
</button> </button>
</div>
)}
{modalType === MODAL_TYPE.CHOOSE_FILTERING_LIST && renderFilters(filtersCatalog, selectedSources, t)}
{modalType !== MODAL_TYPE.CHOOSE_FILTERING_LIST && modalType !== MODAL_TYPE.SELECT_MODAL_TYPE && (
<>
<div className="form__group">
<Field
id="name"
name="name"
type="text"
component={renderInputField}
className="form-control"
placeholder={t('enter_name_hint')}
normalizeOnBlur={(data: any) => data.trim()}
/>
</div> </div>
)}
{modalType === MODAL_TYPE.CHOOSE_FILTERING_LIST && (
<FiltersList
categories={filtersCatalog.categories}
filters={filtersCatalog.filters}
selectedSources={selectedSources}
/>
)}
{modalType !== MODAL_TYPE.CHOOSE_FILTERING_LIST && modalType !== MODAL_TYPE.SELECT_MODAL_TYPE && (
<>
<div className="form__group">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="filters_name"
placeholder={t('enter_name_hint')}
error={fieldState.error?.message}
trimOnBlur
/>
)}
/>
</div>
<div className="form__group"> <div className="form__group">
<Field <Controller
id="url" name="url"
name="url" control={control}
type="text" rules={{ validate: { validateRequiredValue, validatePath } }}
component={renderInputField} render={({ field, fieldState }) => (
className="form-control" <Input
placeholder={t('enter_url_or_path_hint')} {...field}
validate={[validateRequiredValue, validatePath]} type="text"
normalizeOnBlur={(data: any) => data.trim()} data-testid="filters_url"
/> placeholder={t('enter_url_or_path_hint')}
</div> error={fieldState.error?.message}
trimOnBlur
/>
)}
/>
</div>
<div className="form__description"> <div className="form__description">
{whitelist ? t('enter_valid_allowlist') : t('enter_valid_blocklist')} {whitelist ? t('enter_valid_allowlist') : t('enter_valid_blocklist')}
</div> </div>
</> </>
)} )}
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={closeModal}> <button type="button" className="btn btn-secondary" onClick={closeModal}>
{t('cancel_btn')} {t('cancel_btn')}
</button>
{modalType !== MODAL_TYPE.SELECT_MODAL_TYPE && (
<button
type="submit"
className="btn btn-success"
disabled={processingAddFilter || processingConfigFilter}>
{t('save_btn')}
</button> </button>
)}
</div> {modalType !== MODAL_TYPE.SELECT_MODAL_TYPE && (
</form> <button
type="submit"
data-testid="filters_save"
className="btn btn-success"
disabled={processingAddFilter || processingConfigFilter}>
{t('save_btn')}
</button>
)}
</div>
</form>
</FormProvider>
); );
}; };
export default flow([withTranslation(), reduxForm({ form: FORM_NAME.FILTER })])(Form);

View File

@@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next';
import { MODAL_TYPE } from '../../helpers/constants'; import { MODAL_TYPE } from '../../helpers/constants';
import Form from './Form'; import { Form } from './Form';
import '../ui/Modal.css'; import '../ui/Modal.css';
import { getMap } from '../../helpers/helpers'; import { getMap } from '../../helpers/helpers';
@@ -75,25 +75,15 @@ class Modal extends Component<ModalProps> {
render() { render() {
const { const {
isOpen, isOpen,
processingAddFilter, processingAddFilter,
processingConfigFilter, processingConfigFilter,
handleSubmit, handleSubmit,
modalType, modalType,
currentFilterData, currentFilterData,
whitelist, whitelist,
toggleFilteringModal, toggleFilteringModal,
filters, filters,
t, t,
filtersCatalog, filtersCatalog,
} = this.props; } = this.props;

View File

@@ -1,42 +1,69 @@
import React from 'react'; import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { Field, reduxForm } from 'redux-form';
import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import { renderInputField } from '../../../helpers/form';
import { validateAnswer, validateDomain, validateRequiredValue } from '../../../helpers/validators'; import { validateAnswer, validateDomain, validateRequiredValue } from '../../../helpers/validators';
import { FORM_NAME } from '../../../helpers/constants'; import { Input } from '../../ui/Controls/Input';
interface FormProps { interface RewriteFormValues {
pristine: boolean; domain: string;
handleSubmit: (...args: unknown[]) => string; answer: string;
reset: (...args: unknown[]) => string;
toggleRewritesModal: (...args: unknown[]) => unknown;
submitting: boolean;
processingAdd: boolean;
t: (...args: unknown[]) => string;
initialValues?: object;
} }
const Form = (props: FormProps) => { type Props = {
const { t, handleSubmit, reset, pristine, submitting, toggleRewritesModal, processingAdd } = props; processingAdd: boolean;
currentRewrite?: RewriteFormValues;
toggleRewritesModal: () => void;
onSubmit?: (data: RewriteFormValues) => Promise<void> | void;
};
const Form = ({ processingAdd, currentRewrite, toggleRewritesModal, onSubmit }: Props) => {
const { t } = useTranslation();
const {
handleSubmit,
reset,
control,
formState: { isDirty, isSubmitting },
} = useForm<RewriteFormValues>({
mode: 'onBlur',
defaultValues: {
domain: currentRewrite?.domain || '',
answer: currentRewrite?.answer || '',
},
});
const handleFormSubmit = async (data: RewriteFormValues) => {
if (onSubmit) {
await onSubmit(data);
}
};
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="modal-body"> <div className="modal-body">
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">
<Trans>domain_desc</Trans> <Trans>domain_desc</Trans>
</div> </div>
<div className="form__group"> <div className="form__group">
<Field <Controller
id="domain"
name="domain" name="domain"
component={renderInputField} control={control}
type="text" rules={{
className="form-control" validate: {
placeholder={t('form_domain')} validate: validateDomain,
validate={[validateRequiredValue, validateDomain]} required: validateRequiredValue,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="rewrites_domain"
placeholder={t('form_domain')}
error={fieldState.error?.message}
/>
)}
/> />
</div> </div>
<Trans>examples_title</Trans>: <Trans>examples_title</Trans>:
@@ -44,7 +71,6 @@ const Form = (props: FormProps) => {
<li> <li>
<code>example.org</code> <Trans>example_rewrite_domain</Trans> <code>example.org</code> <Trans>example_rewrite_domain</Trans>
</li> </li>
<li> <li>
<code>*.example.org</code> &nbsp; <code>*.example.org</code> &nbsp;
<span> <span>
@@ -53,14 +79,24 @@ const Form = (props: FormProps) => {
</li> </li>
</ol> </ol>
<div className="form__group"> <div className="form__group">
<Field <Controller
id="answer"
name="answer" name="answer"
component={renderInputField} control={control}
type="text" rules={{
className="form-control" validate: {
placeholder={t('form_answer')} validate: validateAnswer,
validate={[validateRequiredValue, validateAnswer]} required: validateRequiredValue,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="rewrites_answer"
placeholder={t('form_answer')}
error={fieldState.error?.message}
/>
)}
/> />
</div> </div>
</div> </div>
@@ -77,8 +113,9 @@ const Form = (props: FormProps) => {
<div className="btn-list"> <div className="btn-list">
<button <button
type="button" type="button"
data-testid="rewrites_cancel"
className="btn btn-secondary btn-standard" className="btn btn-secondary btn-standard"
disabled={submitting || processingAdd} disabled={isSubmitting || processingAdd}
onClick={() => { onClick={() => {
reset(); reset();
toggleRewritesModal(); toggleRewritesModal();
@@ -88,8 +125,9 @@ const Form = (props: FormProps) => {
<button <button
type="submit" type="submit"
data-testid="rewrites_save"
className="btn btn-success btn-standard" className="btn btn-success btn-standard"
disabled={submitting || pristine || processingAdd}> disabled={isSubmitting || !isDirty || processingAdd}>
<Trans>save_btn</Trans> <Trans>save_btn</Trans>
</button> </button>
</div> </div>
@@ -98,10 +136,4 @@ const Form = (props: FormProps) => {
); );
}; };
export default flow([ export default Form;
withTranslation(),
reduxForm({
form: FORM_NAME.REWRITES,
enableReinitialize: true,
}),
])(Form);

View File

@@ -14,7 +14,7 @@ interface ModalProps {
processingAdd: boolean; processingAdd: boolean;
processingDelete: boolean; processingDelete: boolean;
modalType: string; modalType: string;
currentRewrite?: object; currentRewrite?: { answer: string, domain: string; };
} }
const Modal = (props: ModalProps) => { const Modal = (props: ModalProps) => {
@@ -23,7 +23,6 @@ const Modal = (props: ModalProps) => {
handleSubmit, handleSubmit,
toggleRewritesModal, toggleRewritesModal,
processingAdd, processingAdd,
processingDelete,
modalType, modalType,
currentRewrite, currentRewrite,
} = props; } = props;
@@ -50,11 +49,10 @@ const Modal = (props: ModalProps) => {
</div> </div>
<Form <Form
initialValues={{ ...currentRewrite }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
toggleRewritesModal={toggleRewritesModal} toggleRewritesModal={toggleRewritesModal}
processingAdd={processingAdd} processingAdd={processingAdd}
processingDelete={processingDelete} currentRewrite={currentRewrite}
/> />
</div> </div>
</ReactModal> </ReactModal>

View File

@@ -1,38 +1,55 @@
import React from 'react'; import React from 'react';
import { Field, reduxForm } from 'redux-form'; import { Trans } from 'react-i18next';
import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import { toggleAllServices } from '../../../helpers/helpers'; import { Controller, useForm } from 'react-hook-form';
import { renderServiceField } from '../../../helpers/form'; import { ServiceField } from './ServiceField';
import { FORM_NAME } from '../../../helpers/constants';
export type BlockedService = {
id: string;
name: string;
icon_svg: string;
};
type FormValues = {
blocked_services: Record<string, boolean>;
};
interface FormProps { interface FormProps {
blockedServices: unknown[]; initialValues: Record<string, boolean>;
pristine: boolean; blockedServices: BlockedService[];
handleSubmit: (...args: unknown[]) => string; onSubmit: (values: FormValues) => void;
change: (...args: unknown[]) => unknown;
submitting: boolean;
processing: boolean; processing: boolean;
processingSet: boolean; processingSet: boolean;
t: (...args: unknown[]) => string;
} }
const Form = (props: FormProps) => { export const Form = ({ initialValues, blockedServices, processing, processingSet, onSubmit }: FormProps) => {
const { blockedServices, handleSubmit, change, pristine, submitting, processing, processingSet } = props; const {
handleSubmit,
control,
setValue,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onBlur',
defaultValues: initialValues,
});
const handleToggleAllServices = async (isSelected: boolean) => {
blockedServices.forEach((service: BlockedService) => setValue(`blocked_services.${service.id}`, isSelected));
};
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="form__group"> <div className="form__group">
<div className="row mb-4"> <div className="row mb-4">
<div className="col-6"> <div className="col-6">
<button <button
type="button" type="button"
data-testid="blocked_services_block_all"
className="btn btn-secondary btn-block" className="btn btn-secondary btn-block"
disabled={processing || processingSet} disabled={processing || processingSet}
onClick={() => toggleAllServices(blockedServices, change, true)}> onClick={() => handleToggleAllServices(true)}>
<Trans>block_all</Trans> <Trans>block_all</Trans>
</button> </button>
</div> </div>
@@ -40,24 +57,30 @@ const Form = (props: FormProps) => {
<div className="col-6"> <div className="col-6">
<button <button
type="button" type="button"
data-testid="blocked_services_unblock_all"
className="btn btn-secondary btn-block" className="btn btn-secondary btn-block"
disabled={processing || processingSet} disabled={processing || processingSet}
onClick={() => toggleAllServices(blockedServices, change, false)}> onClick={() => handleToggleAllServices(false)}>
<Trans>unblock_all</Trans> <Trans>unblock_all</Trans>
</button> </button>
</div> </div>
</div> </div>
<div className="services"> <div className="services">
{blockedServices.map((service: any) => ( {blockedServices.map((service: BlockedService) => (
<Field <Controller
key={service.id} key={service.id}
icon={service.icon_svg}
name={`blocked_services.${service.id}`} name={`blocked_services.${service.id}`}
type="checkbox" control={control}
component={renderServiceField} render={({ field }) => (
placeholder={service.name} <ServiceField
disabled={processing || processingSet} {...field}
data-testid={`blocked_services_${service.id}`}
placeholder={service.name}
disabled={processing || processingSet}
icon={service.icon_svg}
/>
)}
/> />
))} ))}
</div> </div>
@@ -66,19 +89,12 @@ const Form = (props: FormProps) => {
<div className="btn-list"> <div className="btn-list">
<button <button
type="submit" type="submit"
data-testid="blocked_services_save"
className="btn btn-success btn-standard btn-large" className="btn btn-success btn-standard btn-large"
disabled={submitting || pristine || processing || processingSet}> disabled={isSubmitting || processing || processingSet}>
<Trans>save_btn</Trans> <Trans>save_btn</Trans>
</button> </button>
</div> </div>
</form> </form>
); );
}; };
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.SERVICES,
enableReinitialize: true,
}),
])(Form);

View File

@@ -0,0 +1,42 @@
import React from 'react';
import cn from 'classnames';
import { FieldValues, ControllerRenderProps } from 'react-hook-form';
type Props = ControllerRenderProps<FieldValues> & {
placeholder: string;
disabled?: boolean;
className?: string;
icon?: string;
error?: string;
};
export const ServiceField = React.forwardRef<HTMLInputElement, Props>(
({ name, value, onChange, onBlur, placeholder, disabled, className, icon, error, ...rest }, ref) => (
<>
<label className={cn('service custom-switch', className)}>
<input
name={name}
type="checkbox"
className="custom-switch-input"
checked={!!value}
onChange={onChange}
onBlur={onBlur}
ref={ref}
disabled={disabled}
{...rest}
/>
<span className="service__switch custom-switch-indicator"></span>
<span className="service__text" title={placeholder}>
{placeholder}
</span>
{icon && <div dangerouslySetInnerHTML={{ __html: window.atob(icon) }} className="service__icon" />}
</label>
{!disabled && error && <span className="form__message form__message--error">{error}</span>}
</>
),
);
ServiceField.displayName = 'ServiceField';

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import Form from './Form'; import { Form } from './Form';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import { getBlockedServices, getAllBlockedServices, updateBlockedServices } from '../../../actions/services'; import { getBlockedServices, getAllBlockedServices, updateBlockedServices } from '../../../actions/services';
@@ -86,7 +86,8 @@ const Services = () => {
<Card <Card
title={t('schedule_services')} title={t('schedule_services')}
subtitle={t('schedule_services_desc')} subtitle={t('schedule_services_desc')}
bodyType="card-body box-body--settings"> bodyType="card-body box-body--settings"
>
<ScheduleForm schedule={services.list.schedule} onScheduleSubmit={handleScheduleSubmit} /> <ScheduleForm schedule={services.list.schedule} onScheduleSubmit={handleScheduleSubmit} />
</Card> </Card>
</> </>

View File

@@ -1,158 +1,71 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Field, type InjectedFormProps, reduxForm } from 'redux-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { useFormContext } from 'react-hook-form';
import { import {
DEBOUNCE_FILTER_TIMEOUT, DEBOUNCE_FILTER_TIMEOUT,
DEFAULT_LOGS_FILTER, DEFAULT_LOGS_FILTER,
FORM_NAME,
RESPONSE_FILTER, RESPONSE_FILTER,
RESPONSE_FILTER_QUERIES, RESPONSE_FILTER_QUERIES,
} from '../../../helpers/constants'; } from '../../../helpers/constants';
import { setLogsFilter } from '../../../actions/queryLogs'; import { setLogsFilter } from '../../../actions/queryLogs';
import useDebounce from '../../../helpers/useDebounce'; import useDebounce from '../../../helpers/useDebounce';
import { createOnBlurHandler, getLogsUrlParams } from '../../../helpers/helpers'; import { getLogsUrlParams } from '../../../helpers/helpers';
import Tooltip from '../../ui/Tooltip'; import { SearchField } from './SearchField';
import { RootState } from '../../../initialState'; import { SearchFormValues } from '..';
interface renderFilterFieldProps { type Props = {
input: {
value: string;
};
id: string;
onClearInputClick: (...args: unknown[]) => unknown;
className?: string; className?: string;
placeholder?: string; setIsLoading: (value: boolean) => void;
type?: string;
disabled?: boolean;
autoComplete?: string;
tooltip?: string;
onKeyDown?: (...args: unknown[]) => unknown;
normalizeOnBlur?: (...args: unknown[]) => unknown;
meta: {
touched?: boolean;
error?: object;
};
}
const renderFilterField = ({
input,
id,
className,
placeholder,
type,
disabled,
autoComplete,
tooltip,
meta: { touched, error },
onClearInputClick,
onKeyDown,
normalizeOnBlur,
}: renderFilterFieldProps) => {
const onBlur = (event: any) => createOnBlurHandler(event, input, normalizeOnBlur);
return (
<>
<div className="input-group-search input-group-search__icon--magnifier">
<svg className="icons icon--24 icon--gray">
<use xlinkHref="#magnifier" />
</svg>
</div>
<input
{...input}
id={id}
placeholder={placeholder}
type={type}
className={className}
disabled={disabled}
autoComplete={autoComplete}
aria-label={placeholder}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
<div
className={classNames('input-group-search input-group-search__icon--cross', {
invisible: input.value.length < 1,
})}>
<svg className="icons icon--20 icon--gray" onClick={onClearInputClick}>
<use xlinkHref="#cross" />
</svg>
</div>
<span className="input-group-search input-group-search__icon--tooltip">
<Tooltip content={tooltip} className="tooltip-container">
<svg className="icons icon--20 icon--gray">
<use xlinkHref="#question" />
</svg>
</Tooltip>
</span>
{!disabled && touched && error && <span className="form__message form__message--error">{error}</span>}
</>
);
}; };
const FORM_NAMES = { export const Form = ({ className, setIsLoading }: Props) => {
search: 'search',
response_status: 'response_status',
};
type FiltersFormProps = {
className?: string;
responseStatusClass?: string;
setIsLoading: (...args: unknown[]) => unknown;
};
const Form = (props: FiltersFormProps & InjectedFormProps) => {
const { className = '', responseStatusClass, setIsLoading, change } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const history = useHistory(); const history = useHistory();
const { response_status, search } = useSelector( const { register, watch, setValue } = useFormContext<SearchFormValues>();
(state: RootState) => state?.form[FORM_NAME.LOGS_FILTER].values,
shallowEqual,
);
const [debouncedSearch, setDebouncedSearch] = useDebounce(search.trim(), DEBOUNCE_FILTER_TIMEOUT); const searchValue = watch('search');
const responseStatusValue = watch('response_status');
const [debouncedSearch, setDebouncedSearch] = useDebounce(searchValue.trim(), DEBOUNCE_FILTER_TIMEOUT);
useEffect(() => { useEffect(() => {
dispatch( dispatch(
setLogsFilter({ setLogsFilter({
response_status, response_status: responseStatusValue,
search: debouncedSearch, search: debouncedSearch,
}), }),
); );
history.replace(`${getLogsUrlParams(debouncedSearch, response_status)}`); history.replace(`${getLogsUrlParams(debouncedSearch, responseStatusValue)}`);
}, [response_status, debouncedSearch]); }, [responseStatusValue, debouncedSearch]);
if (response_status && !(response_status in RESPONSE_FILTER_QUERIES)) { useEffect(() => {
change(FORM_NAMES.response_status, DEFAULT_LOGS_FILTER[FORM_NAMES.response_status]); if (responseStatusValue && !(responseStatusValue in RESPONSE_FILTER_QUERIES)) {
} setValue('response_status', DEFAULT_LOGS_FILTER.response_status);
}
}, [responseStatusValue, setValue]);
const onInputClear = async () => { const onInputClear = async () => {
setIsLoading(true); setIsLoading(true);
change(FORM_NAMES.search, DEFAULT_LOGS_FILTER[FORM_NAMES.search]); setValue('search', DEFAULT_LOGS_FILTER.search);
setIsLoading(false); setIsLoading(false);
}; };
const onEnterPress = (e: any) => { const onEnterPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
setDebouncedSearch(search); setDebouncedSearch(searchValue);
} }
}; };
const normalizeOnBlur = (data: any) => data.trim();
return ( return (
<form <form
className="d-flex flex-wrap form-control--container" className="d-flex flex-wrap form-control--container"
@@ -160,40 +73,28 @@ const Form = (props: FiltersFormProps & InjectedFormProps) => {
e.preventDefault(); e.preventDefault();
}}> }}>
<div className="field__search"> <div className="field__search">
<Field <SearchField
id={FORM_NAMES.search} value={searchValue}
name={FORM_NAMES.search} handleChange={(val) => setValue('search', val)}
component={renderFilterField} onKeyDown={onEnterPress}
type="text" onClear={onInputClear}
className={classNames('form-control form-control--search form-control--transparent', className)}
placeholder={t('domain_or_client')} placeholder={t('domain_or_client')}
tooltip={t('query_log_strict_search')} tooltip={t('query_log_strict_search')}
onClearInputClick={onInputClear} className={classNames('form-control form-control--search form-control--transparent', className)}
onKeyDown={onEnterPress}
normalizeOnBlur={normalizeOnBlur}
/> />
</div> </div>
<div className="field__select"> <div className="field__select">
<Field <select
name={FORM_NAMES.response_status} {...register('response_status')}
component="select" className="form-control custom-select custom-select--logs custom-select__arrow--left form-control--transparent d-sm-block">
className={classNames(
'form-control custom-select custom-select--logs custom-select__arrow--left form-control--transparent',
responseStatusClass,
)}>
{Object.values(RESPONSE_FILTER).map(({ QUERY, LABEL, disabled }: any) => ( {Object.values(RESPONSE_FILTER).map(({ QUERY, LABEL, disabled }: any) => (
<option key={LABEL} value={QUERY} disabled={disabled}> <option key={LABEL} value={QUERY} disabled={disabled}>
{t(LABEL)} {t(LABEL)}
</option> </option>
))} ))}
</Field> </select>
</div> </div>
</form> </form>
); );
}; };
export const FiltersForm = reduxForm<Record<string, any>, FiltersFormProps>({
form: FORM_NAME.LOGS_FILTER,
enableReinitialize: true,
})(Form);

View File

@@ -0,0 +1,62 @@
import React, { ComponentProps } from 'react';
import Tooltip from '../../ui/Tooltip';
interface Props extends ComponentProps<'input'> {
handleChange: (newValue: string) => void;
onClear: () => void;
tooltip?: string;
}
export const SearchField = ({
handleChange,
onClear,
value,
tooltip,
className,
...rest
}: Props) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleChange(e.target.value);
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.value = e.target.value.trim();
handleChange(e.target.value)
}
return (
<>
<div className="input-group-search input-group-search__icon--magnifier">
<svg className="icons icon--24 icon--gray">
<use xlinkHref="#magnifier" />
</svg>
</div>
<input
className={className}
value={value}
onChange={handleInputChange}
onBlur={handleBlur}
{...rest}
/>
{typeof value === 'string' && value.length > 0 && (
<div
className="input-group-search input-group-search__icon--cross"
onClick={onClear}
>
<svg className="icons icon--20 icon--gray">
<use xlinkHref="#cross" />
</svg>
</div>
)}
{tooltip && (
<span className="input-group-search input-group-search__icon--tooltip">
<Tooltip content={tooltip} className="tooltip-container">
<svg className="icons icon--20 icon--gray">
<use xlinkHref="#question" />
</svg>
</Tooltip>
</span>
)}
</>
);
};

View File

@@ -2,17 +2,16 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { FiltersForm } from './Form'; import { Form } from './Form';
import { refreshFilteredLogs } from '../../../actions/queryLogs'; import { refreshFilteredLogs } from '../../../actions/queryLogs';
import { addSuccessToast } from '../../../actions/toasts'; import { addSuccessToast } from '../../../actions/toasts';
interface FiltersProps { interface FiltersProps {
filter: object;
processingGetLogs: boolean; processingGetLogs: boolean;
setIsLoading: (...args: unknown[]) => unknown; setIsLoading: (...args: unknown[]) => unknown;
} }
const Filters = ({ filter, setIsLoading }: FiltersProps) => { const Filters = ({ setIsLoading }: FiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -38,7 +37,9 @@ const Filters = ({ filter, setIsLoading }: FiltersProps) => {
</svg> </svg>
</button> </button>
</h1> </h1>
<FiltersForm responseStatusClass="d-sm-block" setIsLoading={setIsLoading} initialValues={filter} /> <Form
setIsLoading={setIsLoading}
/>
</div> </div>
); );
}; };

View File

@@ -18,6 +18,7 @@ interface InfiniteTableProps {
isLoading: boolean; isLoading: boolean;
items: unknown[]; items: unknown[];
isSmallScreen: boolean; isSmallScreen: boolean;
currentQuery: string;
setDetailedDataCurrent: Dispatch<SetStateAction<any>>; setDetailedDataCurrent: Dispatch<SetStateAction<any>>;
setButtonType: (...args: unknown[]) => unknown; setButtonType: (...args: unknown[]) => unknown;
setModalOpened: (...args: unknown[]) => unknown; setModalOpened: (...args: unknown[]) => unknown;
@@ -27,6 +28,7 @@ const InfiniteTable = ({
isLoading, isLoading,
items, items,
isSmallScreen, isSmallScreen,
currentQuery,
setDetailedDataCurrent, setDetailedDataCurrent,
setButtonType, setButtonType,
setModalOpened, setModalOpened,
@@ -43,7 +45,7 @@ const InfiniteTable = ({
const listener = useCallback(() => { const listener = useCallback(() => {
if (!loadingRef.current && loader.current && isScrolledIntoView(loader.current)) { if (!loadingRef.current && loader.current && isScrolledIntoView(loader.current)) {
dispatch(getLogs()); dispatch(getLogs(currentQuery));
} }
}, []); }, []);

View File

@@ -7,7 +7,8 @@ import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import queryString from 'query-string'; import queryString from 'query-string';
import classNames from 'classnames'; import classNames from 'classnames';
import { BLOCK_ACTIONS, MEDIUM_SCREEN_SIZE } from '../../helpers/constants'; import { FormProvider, useForm } from 'react-hook-form';
import { BLOCK_ACTIONS, DEFAULT_LOGS_FILTER, MEDIUM_SCREEN_SIZE } from '../../helpers/constants';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
@@ -29,7 +30,12 @@ import { BUTTON_PREFIX } from './Cells/helpers';
import AnonymizerNotification from './AnonymizerNotification'; import AnonymizerNotification from './AnonymizerNotification';
import { RootState } from '../../initialState'; import { RootState } from '../../initialState';
const processContent = (data: any, buttonType: string) => export type SearchFormValues = {
search: string;
response_status: string;
};
const processContent = (data: any, _buttonType: string) =>
Object.entries(data).map(([key, value]) => { Object.entries(data).map(([key, value]) => {
if (!value) { if (!value) {
return null; return null;
@@ -76,7 +82,6 @@ const Logs = () => {
const { const {
enabled, enabled,
processingGetConfig, processingGetConfig,
// processingAdditionalLogs,
processingGetLogs, processingGetLogs,
anonymize_client_ip: anonymizeClientIp, anonymize_client_ip: anonymizeClientIp,
} = useSelector((state: RootState) => state.queryLogs, shallowEqual); } = useSelector((state: RootState) => state.queryLogs, shallowEqual);
@@ -88,6 +93,17 @@ const Logs = () => {
const search = search_url_param || filter?.search || ''; const search = search_url_param || filter?.search || '';
const response_status = response_status_url_param || filter?.response_status || ''; const response_status = response_status_url_param || filter?.response_status || '';
const formMethods = useForm<SearchFormValues>({
mode: 'onBlur',
defaultValues: {
search: search || DEFAULT_LOGS_FILTER.search,
response_status: response_status || DEFAULT_LOGS_FILTER.response_status,
},
});
const { watch } = formMethods;
const currentQuery = watch('search');
const [isSmallScreen, setIsSmallScreen] = useState(window.innerWidth <= MEDIUM_SCREEN_SIZE); const [isSmallScreen, setIsSmallScreen] = useState(window.innerWidth <= MEDIUM_SCREEN_SIZE);
const [detailedDataCurrent, setDetailedDataCurrent] = useState({}); const [detailedDataCurrent, setDetailedDataCurrent] = useState({});
const [buttonType, setButtonType] = useState(BLOCK_ACTIONS.BLOCK); const [buttonType, setButtonType] = useState(BLOCK_ACTIONS.BLOCK);
@@ -174,15 +190,12 @@ const Logs = () => {
const renderPage = () => ( const renderPage = () => (
<> <>
<Filters <FormProvider {...formMethods}>
filter={{ <Filters
response_status, setIsLoading={setIsLoading}
search, processingGetLogs={processingGetLogs}
}} />
setIsLoading={setIsLoading} </FormProvider>
processingGetLogs={processingGetLogs}
// processingAdditionalLogs={processingAdditionalLogs}
/>
<InfiniteTable <InfiniteTable
isLoading={isLoading} isLoading={isLoading}
@@ -191,6 +204,7 @@ const Logs = () => {
setDetailedDataCurrent={setDetailedDataCurrent} setDetailedDataCurrent={setDetailedDataCurrent}
setButtonType={setButtonType} setButtonType={setButtonType}
setModalOpened={setModalOpened} setModalOpened={setModalOpened}
currentQuery={currentQuery}
/> />
<Modal <Modal

View File

@@ -111,6 +111,12 @@ const ClientsTable = ({
config.tags = []; config.tags = [];
} }
if (values.ids) {
config.ids = values.ids.map((id) => id.name);
} else {
config.ids = [];
}
if (typeof values.upstreams_cache_size === 'string') { if (typeof values.upstreams_cache_size === 'string') {
config.upstreams_cache_size = 0; config.upstreams_cache_size = 0;
} }

View File

@@ -1,514 +0,0 @@
import React, { useState } from 'react';
import { connect, useSelector } from 'react-redux';
import { Field, FieldArray, reduxForm, formValueSelector, FormErrors } from 'redux-form';
import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import Select from 'react-select';
import i18n from '../../../i18n';
import Tabs from '../../ui/Tabs';
import Examples from '../Dns/Upstream/Examples';
import { ScheduleForm } from '../../Filters/Services/ScheduleForm';
import { toggleAllServices, trimLinesAndRemoveEmpty, captitalizeWords } from '../../../helpers/helpers';
import {
toNumber,
renderInputField,
renderGroupField,
CheckboxField,
renderServiceField,
renderTextareaField,
} from '../../../helpers/form';
import { validateClientId, validateRequiredValue } from '../../../helpers/validators';
import { CLIENT_ID_LINK, FORM_NAME, UINT32_RANGE } from '../../../helpers/constants';
import './Service.css';
import { RootState } from '../../../initialState';
const settingsCheckboxes = [
{
name: 'use_global_settings',
placeholder: 'client_global_settings',
},
{
name: 'filtering_enabled',
placeholder: 'block_domain_use_filters_and_hosts',
},
{
name: 'safebrowsing_enabled',
placeholder: 'use_adguard_browsing_sec',
},
{
name: 'parental_enabled',
placeholder: 'use_adguard_parental',
},
];
const logAndStatsCheckboxes = [
{
name: 'ignore_querylog',
placeholder: 'ignore_query_log',
},
{
name: 'ignore_statistics',
placeholder: 'ignore_statistics',
},
];
const validate = (values: any): FormErrors<any, string> => {
const errors: {
name?: string;
ids?: string[];
} = {};
const { name, ids } = values;
errors.name = validateRequiredValue(name);
if (ids && ids.length) {
const idArrayErrors: any = [];
ids.forEach((id: any, idx: any) => {
idArrayErrors[idx] = validateRequiredValue(id) || validateClientId(id);
});
if (idArrayErrors.length) {
errors.ids = idArrayErrors;
}
}
// @ts-expect-error FIXME: ts migration
return errors;
};
const renderFieldsWrapper = (placeholder: any, buttonTitle: any) =>
function cell(row: any) {
const { fields } = row;
return (
<div className="form__group">
{fields.map((ip: any, index: any) => (
<div key={index} className="mb-1">
<Field
name={ip}
component={renderGroupField}
type="text"
className="form-control"
placeholder={placeholder}
isActionAvailable={index !== 0}
removeField={() => fields.remove(index)}
normalizeOnBlur={(data: any) => data.trim()}
/>
</div>
))}
<button
type="button"
className="btn btn-link btn-block btn-sm"
onClick={() => fields.push()}
title={buttonTitle}>
<svg className="icon icon--24">
<use xlinkHref="#plus" />
</svg>
</button>
</div>
);
};
// Should create function outside of component to prevent component re-renders
const renderFields = renderFieldsWrapper(i18n.t('form_enter_id'), i18n.t('form_add_id'));
interface renderMultiselectProps {
input: {
name: string;
value: string;
checked: boolean;
onChange: (...args: unknown[]) => unknown;
onBlur: (...args: unknown[]) => unknown;
};
placeholder?: string;
options?: unknown[];
}
const renderMultiselect = (props: renderMultiselectProps) => {
const { input, placeholder, options } = props;
return (
<Select
{...input}
options={options}
className="basic-multi-select"
classNamePrefix="select"
onChange={(value: any) => input.onChange(value)}
onBlur={() => input.onBlur(input.value)}
placeholder={placeholder}
blurInputOnSelect={false}
isMulti
/>
);
};
interface FormProps {
pristine: boolean;
handleSubmit: (...args: unknown[]) => string;
reset: (...args: unknown[]) => string;
change: (...args: unknown[]) => unknown;
submitting: boolean;
handleClose: (...args: unknown[]) => unknown;
useGlobalSettings?: boolean;
useGlobalServices?: boolean;
blockedServicesSchedule?: {
time_zone: string;
};
t: (...args: unknown[]) => string;
processingAdding: boolean;
processingUpdating: boolean;
invalid: boolean;
tagsOptions: unknown[];
initialValues?: {
safe_search: any;
};
}
let Form = (props: FormProps) => {
const {
t,
handleSubmit,
reset,
change,
submitting,
useGlobalSettings,
useGlobalServices,
blockedServicesSchedule,
handleClose,
processingAdding,
processingUpdating,
invalid,
tagsOptions,
initialValues,
} = props;
const services = useSelector((store: RootState) => store?.services);
const { safe_search } = initialValues;
const safeSearchServices = { ...safe_search };
delete safeSearchServices.enabled;
const [activeTabLabel, setActiveTabLabel] = useState('settings');
const handleScheduleSubmit = (values: any) => {
change('blocked_services_schedule', { ...values });
};
const tabs = {
settings: {
title: 'settings',
component: (
<div title={props.t('main_settings')}>
<div className="form__label--bot form__label--bold">{t('protection_section_label')}</div>
{settingsCheckboxes.map((setting) => (
<div className="form__group" key={setting.name}>
<Field
name={setting.name}
type="checkbox"
component={CheckboxField}
placeholder={t(setting.placeholder)}
disabled={setting.name !== 'use_global_settings' ? useGlobalSettings : false}
/>
</div>
))}
<div className="form__group">
<Field
name="safe_search.enabled"
type="checkbox"
component={CheckboxField}
placeholder={t('enforce_safe_search')}
disabled={useGlobalSettings}
/>
</div>
<div className="form__group--inner">
{Object.keys(safeSearchServices).map((searchKey) => (
<div key={searchKey}>
<Field
name={`safe_search.${searchKey}`}
type="checkbox"
component={CheckboxField}
placeholder={captitalizeWords(searchKey)}
disabled={useGlobalSettings}
/>
</div>
))}
</div>
<div className="form__label--bold form__label--top form__label--bot">
{t('log_and_stats_section_label')}
</div>
{logAndStatsCheckboxes.map((setting) => (
<div className="form__group" key={setting.name}>
<Field
name={setting.name}
type="checkbox"
component={CheckboxField}
placeholder={t(setting.placeholder)}
/>
</div>
))}
</div>
),
},
block_services: {
title: 'block_services',
component: (
<div title={props.t('block_services')}>
<div className="form__group">
<Field
name="use_global_blocked_services"
type="checkbox"
component={renderServiceField}
placeholder={t('blocked_services_global')}
modifier="service--global"
/>
<div className="row mb-4">
<div className="col-6">
<button
type="button"
className="btn btn-secondary btn-block"
disabled={useGlobalServices}
onClick={() => toggleAllServices(services.allServices, change, true)}>
<Trans>block_all</Trans>
</button>
</div>
<div className="col-6">
<button
type="button"
className="btn btn-secondary btn-block"
disabled={useGlobalServices}
onClick={() => toggleAllServices(services.allServices, change, false)}>
<Trans>unblock_all</Trans>
</button>
</div>
</div>
{services.allServices.length > 0 && (
<div className="services">
{services.allServices.map((service: any) => (
<Field
key={service.id}
icon={service.icon_svg}
name={`blocked_services.${service.id}`}
type="checkbox"
component={renderServiceField}
placeholder={service.name}
disabled={useGlobalServices}
/>
))}
</div>
)}
</div>
</div>
),
},
schedule_services: {
title: 'schedule_services',
component: (
<>
<div className="form__desc mb-4">
<Trans>schedule_services_desc_client</Trans>
</div>
<ScheduleForm
schedule={blockedServicesSchedule}
onScheduleSubmit={handleScheduleSubmit}
clientForm
/>
</>
),
},
upstream_dns: {
title: 'upstream_dns',
component: (
<div title={props.t('upstream_dns')}>
<div className="form__desc mb-3">
<Trans
components={[
<a href="#dns" key="0">
link
</a>,
]}>
upstream_dns_client_desc
</Trans>
</div>
<Field
id="upstreams"
name="upstreams"
component={renderTextareaField}
type="text"
className="form-control form-control--textarea mb-5"
placeholder={t('upstream_dns')}
normalizeOnBlur={trimLinesAndRemoveEmpty}
/>
<Examples />
<div className="form__label--bold mt-5 mb-3">{t('upstream_dns_cache_configuration')}</div>
<div className="form__group mb-2">
<Field
name="upstreams_cache_enabled"
type="checkbox"
component={CheckboxField}
placeholder={t('enable_upstream_dns_cache')}
/>
</div>
<div className="form__group form__group--settings">
<label htmlFor="upstreams_cache_size" className="form__label">
{t('dns_cache_size')}
</label>
<Field
name="upstreams_cache_size"
type="number"
component={renderInputField}
placeholder={t('enter_cache_size')}
className="form-control"
normalize={toNumber}
min={0}
max={UINT32_RANGE.MAX}
/>
</div>
</div>
),
},
};
const activeTab = tabs[activeTabLabel].component;
return (
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="form__group mb-0">
<div className="form__group">
<Field
id="name"
name="name"
component={renderInputField}
type="text"
className="form-control"
placeholder={t('form_client_name')}
normalizeOnBlur={(data: any) => data.trim()}
/>
</div>
<div className="form__group mb-4">
<div className="form__label">
<strong className="mr-3">
<Trans>tags_title</Trans>
</strong>
</div>
<div className="form__desc mt-0 mb-2">
<Trans
components={[
<a
target="_blank"
rel="noopener noreferrer"
href="https://link.adtidy.org/forward.html?action=dns_kb_filtering_syntax_ctag&from=ui&app=home"
key="0">
link
</a>,
]}>
tags_desc
</Trans>
</div>
<Field
name="tags"
component={renderMultiselect}
placeholder={t('form_select_tags')}
options={tagsOptions}
/>
</div>
<div className="form__group">
<div className="form__label">
<strong className="mr-3">
<Trans>client_identifier</Trans>
</strong>
</div>
<div className="form__desc mt-0">
<Trans
components={[
<a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer" key="0">
text
</a>,
]}>
client_identifier_desc
</Trans>
</div>
</div>
<div className="form__group">
<FieldArray name="ids" component={renderFields} />
</div>
</div>
<Tabs
controlClass="form"
tabs={tabs}
activeTabLabel={activeTabLabel}
setActiveTabLabel={setActiveTabLabel}>
{activeTab}
</Tabs>
</div>
<div className="modal-footer">
<div className="btn-list">
<button
type="button"
className="btn btn-secondary btn-standard"
disabled={submitting}
onClick={() => {
reset();
handleClose();
}}>
<Trans>cancel_btn</Trans>
</button>
<button
type="submit"
className="btn btn-success btn-standard"
disabled={submitting || invalid || processingAdding || processingUpdating}>
<Trans>save_btn</Trans>
</button>
</div>
</div>
</form>
);
};
const selector = formValueSelector(FORM_NAME.CLIENT);
Form = connect((state) => {
const useGlobalSettings = selector(state, 'use_global_settings');
const useGlobalServices = selector(state, 'use_global_blocked_services');
const blockedServicesSchedule = selector(state, 'blocked_services_schedule');
return {
useGlobalSettings,
useGlobalServices,
blockedServicesSchedule,
};
})(Form);
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.CLIENT,
enableReinitialize: true,
validate,
}),
])(Form);

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { ClientForm } from '../types';
import { BlockedService } from '../../../../Filters/Services/Form';
import { ServiceField } from '../../../../Filters/Services/ServiceField';
type Props = {
services: BlockedService[];
};
export const BlockedServices = ({ services }: Props) => {
const { t } = useTranslation();
const { watch, setValue, control } = useFormContext<ClientForm>();
const useGlobalServices = watch('use_global_blocked_services');
const handleToggleAllServices = (isSelected: boolean) => {
services.forEach((service: BlockedService) => setValue(`blocked_services.${service.id}`, isSelected));
};
return (
<div title={t('block_services')}>
<div className="form__group">
<Controller
name="use_global_blocked_services"
control={control}
render={({ field }) => (
<ServiceField
{...field}
data-testid="clients_use_global_blocked_services"
placeholder={t('blocked_services_global')}
className="service--global"
/>
)}
/>
<div className="row mb-4">
<div className="col-6">
<button
type="button"
data-testid="clients_block_all"
className="btn btn-secondary btn-block"
disabled={useGlobalServices}
onClick={() => handleToggleAllServices(true)}>
<Trans>block_all</Trans>
</button>
</div>
<div className="col-6">
<button
type="button"
data-testid="clients_unblock_all"
className="btn btn-secondary btn-block"
disabled={useGlobalServices}
onClick={() => handleToggleAllServices(false)}>
<Trans>unblock_all</Trans>
</button>
</div>
</div>
{services.length > 0 && (
<div className="services">
{services.map((service: BlockedService) => (
<Controller
key={service.id}
name={`blocked_services.${service.id}`}
control={control}
render={({ field }) => (
<ServiceField
{...field}
data-testid={`clients_service_${service.id}`}
placeholder={service.name}
disabled={useGlobalServices}
icon={service.icon_svg}
/>
)}
/>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { ClientForm } from '../types';
import { Input } from '../../../../ui/Controls/Input';
import { validateClientId, validateRequiredValue } from '../../../../../helpers/validators';
export const ClientIds = () => {
const { t } = useTranslation();
const { control } = useFormContext<ClientForm>();
const { fields, append, remove } = useFieldArray<ClientForm>({
control,
name: 'ids',
});
return (
<div className="form__group">
{fields.map((field, index) => (
<div key={field.id} className="mb-1">
<Controller
name={`ids.${index}.name`}
control={control}
rules={{
validate: {
required: (value) => validateRequiredValue(value),
validId: (value) => validateClientId(value),
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid={`clients_id_${index}`}
placeholder={t('form_enter_id')}
error={fieldState.error?.message}
onBlur={(event) => {
const trimmedValue = event.target.value.trim();
field.onBlur();
field.onChange(trimmedValue);
}}
rightAddon={
index !== 0 && (
<span className="input-group-append">
<button
type="button"
data-testid={`clients_id_remove_${index}`}
className="btn btn-secondary btn-icon btn-icon--green"
onClick={() => remove(index)}>
<svg className="icon icon--24">
<use xlinkHref="#cross" />
</svg>
</button>
</span>
)
}
/>
)}
/>
</div>
))}
<button
type="button"
data-testid="clients_id_add"
className="btn btn-link btn-block btn-sm"
onClick={() => append({ name: '' })}
title={t('form_add_id')}>
<svg className="icon icon--24">
<use xlinkHref="#plus" />
</svg>
</button>
</div>
);
};

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import i18next from 'i18next';
import { captitalizeWords } from '../../../../../helpers/helpers';
import { ClientForm } from '../types';
import { Checkbox } from '../../../../ui/Controls/Checkbox';
type ProtectionSettings = 'use_global_settings' | 'filtering_enabled' | 'safebrowsing_enabled' | 'parental_enabled';
const settingsCheckboxes: {
name: ProtectionSettings;
placeholder: string;
}[] = [
{
name: 'use_global_settings',
placeholder: i18next.t('client_global_settings'),
},
{
name: 'filtering_enabled',
placeholder: i18next.t('block_domain_use_filters_and_hosts'),
},
{
name: 'safebrowsing_enabled',
placeholder: i18next.t('use_adguard_browsing_sec'),
},
{
name: 'parental_enabled',
placeholder: i18next.t('use_adguard_parental'),
},
];
type LogsStatsSettings = 'ignore_querylog' | 'ignore_statistics';
const logAndStatsCheckboxes: { name: LogsStatsSettings; placeholder: string }[] = [
{
name: 'ignore_querylog',
placeholder: i18next.t('ignore_query_log'),
},
{
name: 'ignore_statistics',
placeholder: i18next.t('ignore_statistics'),
},
];
type Props = {
safeSearchServices: Record<string, boolean>;
};
export const MainSettings = ({ safeSearchServices }: Props) => {
const { t } = useTranslation();
const { watch, control } = useFormContext<ClientForm>();
const useGlobalSettings = watch('use_global_settings');
return (
<div title={t('main_settings')}>
<div className="form__label--bot form__label--bold">{t('protection_section_label')}</div>
{settingsCheckboxes.map((setting) => (
<div className="form__group" key={setting.name}>
<Controller
name={setting.name}
control={control}
render={({ field }) => (
<Checkbox
{...field}
data-testid={`clients_${setting.name}`}
title={setting.placeholder}
disabled={setting.name !== 'use_global_settings' ? useGlobalSettings : false}
/>
)}
/>
</div>
))}
<div className="form__group">
<Controller
name="safe_search.enabled"
control={control}
render={({ field }) => (
<Checkbox
data-testid="clients_safe_search"
{...field}
title={t('enforce_safe_search')}
disabled={useGlobalSettings}
/>
)}
/>
</div>
<div className="form__group--inner">
{Object.keys(safeSearchServices).map((searchKey) => (
<div key={searchKey}>
<Controller
name={`safe_search.${searchKey}`}
control={control}
render={({ field }) => (
<Checkbox
{...field}
data-testid={`clients_safe_search_${searchKey}`}
title={captitalizeWords(searchKey)}
disabled={useGlobalSettings}
/>
)}
/>
</div>
))}
</div>
<div className="form__label--bold form__label--top form__label--bot">
{t('log_and_stats_section_label')}
</div>
{logAndStatsCheckboxes.map((setting) => (
<div className="form__group" key={setting.name}>
<Controller
name={setting.name}
control={control}
render={({ field }) => (
<Checkbox {...field} data-testid={`clients_${setting.name}`} title={setting.placeholder} />
)}
/>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { useFormContext } from 'react-hook-form';
import { ScheduleForm } from '../../../../Filters/Services/ScheduleForm';
import { ClientForm } from '../types';
export const ScheduleServices = () => {
const { watch, setValue } = useFormContext<ClientForm>();
const blockedServicesSchedule = watch('blocked_services_schedule');
const handleScheduleSubmit = (values: any) => {
setValue('blocked_services_schedule', values);
};
return (
<>
<div className="form__desc mb-4">
<Trans>schedule_services_desc_client</Trans>
</div>
<ScheduleForm schedule={blockedServicesSchedule} onScheduleSubmit={handleScheduleSubmit} clientForm />
</>
);
};

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import Examples from '../../../Dns/Upstream/Examples';
import { UINT32_RANGE } from '../../../../../helpers/constants';
import { Textarea } from '../../../../ui/Controls/Textarea';
import { ClientForm } from '../types';
import { Checkbox } from '../../../../ui/Controls/Checkbox';
import { Input } from '../../../../ui/Controls/Input';
import { toNumber } from '../../../../../helpers/form';
export const UpstreamDns = () => {
const { t } = useTranslation();
const { control } = useFormContext<ClientForm>();
return (
<div title={t('upstream_dns')}>
<div className="form__desc mb-3">
<Trans components={[<a href="#dns" key="0" />]}>upstream_dns_client_desc</Trans>
</div>
<Controller
name="upstreams"
control={control}
render={({ field }) => (
<Textarea
{...field}
data-testid="clients_upstreams"
className="form-control form-control--textarea mb-5"
placeholder={t('upstream_dns')}
trimOnBlur
/>
)}
/>
<Examples />
<div className="form__label--bold mt-5 mb-3">{t('upstream_dns_cache_configuration')}</div>
<div className="form__group mb-2">
<Controller
name="upstreams_cache_enabled"
control={control}
render={({ field }) => (
<Checkbox
{...field}
data-testid="clients_upstreams_cache_enabled"
title={t('enable_upstream_dns_cache')}
/>
)}
/>
</div>
<div className="form__group form__group--settings">
<label htmlFor="upstreams_cache_size" className="form__label">
{t('dns_cache_size')}
</label>
<Controller
name="upstreams_cache_size"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
data-testid="clients_upstreams_cache_size"
placeholder={t('enter_cache_size')}
error={fieldState.error?.message}
min={0}
max={UINT32_RANGE.MAX}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,5 @@
export { BlockedServices } from './BlockedServices';
export { ClientIds } from './ClientIds';
export { ScheduleServices } from './ScheduleServices';
export { MainSettings } from './MainSettings';
export { UpstreamDns } from './UpstreamDns';

View File

@@ -0,0 +1,223 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { Trans, useTranslation } from 'react-i18next';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import Select from 'react-select';
import Tabs from '../../../ui/Tabs';
import { CLIENT_ID_LINK, LOCAL_TIMEZONE_VALUE } from '../../../../helpers/constants';
import { RootState } from '../../../../initialState';
import { Input } from '../../../ui/Controls/Input';
import { validateRequiredValue } from '../../../../helpers/validators';
import { ClientForm } from './types';
import { BlockedServices, ClientIds, MainSettings, ScheduleServices, UpstreamDns } from './components';
import '../Service.css';
const defaultFormValues: ClientForm = {
ids: [{ name: '' }],
name: '',
tags: [],
use_global_settings: false,
filtering_enabled: false,
safebrowsing_enabled: false,
parental_enabled: false,
ignore_querylog: false,
ignore_statistics: false,
blocked_services: {},
safe_search: { enabled: false },
upstreams: '',
upstreams_cache_enabled: false,
upstreams_cache_size: 0,
use_global_blocked_services: false,
blocked_services_schedule: {
time_zone: LOCAL_TIMEZONE_VALUE,
},
};
type Props = {
onSubmit: (values: ClientForm) => void;
onClose: () => void;
useGlobalSettings?: boolean;
useGlobalServices?: boolean;
blockedServicesSchedule?: {
time_zone: string;
};
processingAdding: boolean;
processingUpdating: boolean;
tagsOptions: { label: string; value: string }[];
initialValues?: ClientForm;
};
export const Form = ({
onSubmit,
onClose,
processingAdding,
processingUpdating,
tagsOptions,
initialValues,
}: Props) => {
const { t } = useTranslation();
const methods = useForm<ClientForm>({
defaultValues: {
...defaultFormValues,
...initialValues,
},
mode: 'onBlur',
});
const {
handleSubmit,
reset,
control,
formState: { isSubmitting, isValid },
} = methods;
const services = useSelector((store: RootState) => store?.services);
const { safe_search } = initialValues;
const safeSearchServices = { ...safe_search };
delete safeSearchServices.enabled;
const [activeTabLabel, setActiveTabLabel] = useState('settings');
const tabs = {
settings: {
title: 'settings',
component: <MainSettings safeSearchServices={safeSearchServices} />,
},
block_services: {
title: 'block_services',
component: <BlockedServices services={services?.allServices} />,
},
schedule_services: {
title: 'schedule_services',
component: <ScheduleServices />,
},
upstream_dns: {
title: 'upstream_dns',
component: <UpstreamDns />,
},
};
const activeTab = tabs[activeTabLabel].component;
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="modal-body">
<div className="form__group mb-0">
<div className="form__group">
<Controller
name="name"
control={control}
rules={{ validate: validateRequiredValue }}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="clients_name"
placeholder={t('form_client_name')}
error={fieldState.error?.message}
onBlur={(event) => {
const trimmedValue = event.target.value.trim();
field.onBlur();
field.onChange(trimmedValue);
}}
/>
)}
/>
</div>
<div className="form__group mb-4">
<div className="form__label">
<strong className="mr-3">
<Trans>tags_title</Trans>
</strong>
</div>
<div className="form__desc mt-0 mb-2">
<Trans
components={[
<a
target="_blank"
rel="noopener noreferrer"
href="https://link.adtidy.org/forward.html?action=dns_kb_filtering_syntax_ctag&from=ui&app=home"
key="0"
/>,
]}>
tags_desc
</Trans>
</div>
<Controller
name="tags"
control={control}
render={({ field }) => (
<Select
{...field}
data-testid="clients_tags"
options={tagsOptions}
className="basic-multi-select"
classNamePrefix="select"
isMulti
/>
)}
/>
</div>
<div className="form__group">
<div className="form__label">
<strong className="mr-3">
<Trans>client_identifier</Trans>
</strong>
</div>
<div className="form__desc mt-0">
<Trans
components={[
<a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer" key="0" />,
]}>
client_identifier_desc
</Trans>
</div>
</div>
<div className="form__group">
<ClientIds />
</div>
</div>
<Tabs
controlClass="form"
tabs={tabs}
activeTabLabel={activeTabLabel}
setActiveTabLabel={setActiveTabLabel}>
{activeTab}
</Tabs>
</div>
<div className="modal-footer">
<div className="btn-list">
<button
type="button"
className="btn btn-secondary btn-standard"
disabled={isSubmitting}
onClick={() => {
reset();
onClose();
}}>
<Trans>cancel_btn</Trans>
</button>
<button
type="submit"
className="btn btn-success btn-standard"
disabled={isSubmitting || !isValid || processingAdding || processingUpdating}>
<Trans>save_btn</Trans>
</button>
</div>
</div>
</form>
</FormProvider>
);
};

View File

@@ -0,0 +1,28 @@
export type ClientForm = {
name: string;
tags: { value: string; label: string }[];
ids: { name: string }[];
use_global_settings: boolean;
use_global_blocked_services: boolean;
blocked_services_schedule: {
time_zone: string;
};
safe_search: {
enabled: boolean;
[key: string]: boolean;
};
upstreams: string;
upstreams_cache_enabled: boolean;
upstreams_cache_size: number;
blocked_services: Record<string, boolean>;
filtering_enabled: boolean;
safebrowsing_enabled: boolean;
parental_enabled: boolean;
ignore_querylog: boolean;
ignore_statistics: boolean;
};
export type SubmitClientForm = Omit<ClientForm, 'ids' | 'tags'> & {
ids: string[];
tags: string[];
};

View File

@@ -4,8 +4,15 @@ import { Trans, withTranslation } from 'react-i18next';
import ReactModal from 'react-modal'; import ReactModal from 'react-modal';
import { MODAL_TYPE } from '../../../helpers/constants'; import { MODAL_TYPE } from '../../../helpers/constants';
import { Form } from './Form';
import Form from './Form'; const normalizeIds = (initialIds?: string[]): { name: string }[] => {
if (!initialIds || initialIds.length === 0) {
return [{ name: '' }];
}
return initialIds.map((id: string) => ({ name: id }));
};
const getInitialData = ({ initial, modalType, clientId, clientName }: any) => { const getInitialData = ({ initial, modalType, clientId, clientName }: any) => {
if (initial && initial.blocked_services) { if (initial && initial.blocked_services) {
@@ -19,6 +26,7 @@ const getInitialData = ({ initial, modalType, clientId, clientName }: any) => {
return { return {
...initial, ...initial,
blocked_services: blocked, blocked_services: blocked,
ids: normalizeIds(initial.ids),
}; };
} }
@@ -26,11 +34,14 @@ const getInitialData = ({ initial, modalType, clientId, clientName }: any) => {
return { return {
...initial, ...initial,
name: clientName, name: clientName,
ids: [clientId], ids: [{ name: clientId }],
}; };
} }
return initial; return {
...initial,
ids: normalizeIds(initial.ids),
};
}; };
interface ModalProps { interface ModalProps {
@@ -41,7 +52,7 @@ interface ModalProps {
handleClose: (...args: unknown[]) => unknown; handleClose: (...args: unknown[]) => unknown;
processingAdding: boolean; processingAdding: boolean;
processingUpdating: boolean; processingUpdating: boolean;
tagsOptions: unknown[]; tagsOptions: { label: string; value: string }[];
t: (...args: unknown[]) => string; t: (...args: unknown[]) => string;
clientId?: string; clientId?: string;
} }
@@ -85,7 +96,7 @@ const Modal = ({
<Form <Form
initialValues={{ ...initialData }} initialValues={{ ...initialData }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
handleClose={handleClose} onClose={handleClose}
processingAdding={processingAdding} processingAdding={processingAdding}
processingUpdating={processingUpdating} processingUpdating={processingUpdating}
tagsOptions={tagsOptions} tagsOptions={tagsOptions}

View File

@@ -1,28 +1,22 @@
import React, { useCallback } from 'react'; import React, { useMemo } from 'react';
import { shallowEqual, useSelector } from 'react-redux'; import { Controller, useFormContext } from 'react-hook-form';
import { Field, reduxForm } from 'redux-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { renderInputField, toNumber } from '../../../helpers/form'; import { UINT32_RANGE } from '../../../helpers/constants';
import { FORM_NAME, UINT32_RANGE } from '../../../helpers/constants';
import { import {
validateIpv4,
validateRequiredValue,
validateIpv4RangeEnd,
validateGatewaySubnetMask, validateGatewaySubnetMask,
validateIpForGatewaySubnetMask, validateIpForGatewaySubnetMask,
validateIpv4,
validateIpv4RangeEnd,
validateNotInRange, validateNotInRange,
validateRequiredValue,
} from '../../../helpers/validators'; } from '../../../helpers/validators';
import { RootState } from '../../../initialState'; import { DhcpFormValues } from '.';
import { Input } from '../../ui/Controls/Input';
import { toNumber } from '../../../helpers/form';
interface FormDHCPv4Props { type FormDHCPv4Props = {
handleSubmit: (...args: unknown[]) => string;
submitting: boolean;
initialValues: { v4?: any };
processingConfig?: boolean; processingConfig?: boolean;
change: (field: string, value: any) => void;
reset: () => void;
ipv4placeholders?: { ipv4placeholders?: {
gateway_ip: string; gateway_ip: string;
subnet_mask: string; subnet_mask: string;
@@ -30,127 +24,179 @@ interface FormDHCPv4Props {
range_end: string; range_end: string;
lease_duration: string; lease_duration: string;
}; };
} interfaces: any;
onSubmit?: (data: DhcpFormValues) => void;
};
const FormDHCPv4 = ({ handleSubmit, submitting, processingConfig, ipv4placeholders }: FormDHCPv4Props) => { const FormDHCPv4 = ({ processingConfig, ipv4placeholders, interfaces, onSubmit }: FormDHCPv4Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const dhcp = useSelector((state: RootState) => state.form[FORM_NAME.DHCPv4], shallowEqual); const {
handleSubmit,
formState: { errors, isSubmitting },
control,
watch,
} = useFormContext<DhcpFormValues>();
const interfaces = useSelector((state: RootState) => state.form[FORM_NAME.DHCP_INTERFACES], shallowEqual); const interfaceName = watch('interface_name');
const interface_name = interfaces?.values?.interface_name; const isInterfaceIncludesIpv4 = interfaces?.[interfaceName]?.ipv4_addresses;
const isInterfaceIncludesIpv4 = useSelector( const formValues = watch('v4');
(state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv4_addresses, const isEmptyConfig = !Object.values(formValues || {}).some(Boolean);
); const hasV4Errors = errors.v4 && Object.keys(errors.v4).length > 0;
const isEmptyConfig = !Object.values(dhcp?.values?.v4 ?? {}).some(Boolean); const isDisabled = useMemo(() => {
return isSubmitting || hasV4Errors || processingConfig || !isInterfaceIncludesIpv4 || isEmptyConfig;
const invalid = }, [isSubmitting, hasV4Errors, processingConfig, isInterfaceIncludesIpv4, isEmptyConfig]);
dhcp?.syncErrors ||
interfaces?.syncErrors ||
!isInterfaceIncludesIpv4 ||
isEmptyConfig ||
submitting ||
processingConfig;
const validateRequired = useCallback(
(value) => {
if (isEmptyConfig) {
return undefined;
}
return validateRequiredValue(value);
},
[isEmptyConfig],
);
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="row"> <div className="row">
<div className="col-lg-6"> <div className="col-lg-6">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label>{t('dhcp_form_gateway_input')}</label> <Controller
<Field
name="v4.gateway_ip" name="v4.gateway_ip"
component={renderInputField} control={control}
type="text" rules={{
className="form-control" validate: {
placeholder={t(ipv4placeholders.gateway_ip)} ipv4: validateIpv4,
validate={[validateIpv4, validateRequired, validateNotInRange]} required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
disabled={!isInterfaceIncludesIpv4} notInRange: validateNotInRange,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="v4_gateway_ip"
label={t('dhcp_form_gateway_input')}
placeholder={t(ipv4placeholders.gateway_ip)}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv4}
/>
)}
/> />
</div> </div>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label>{t('dhcp_form_subnet_input')}</label> <Controller
<Field
name="v4.subnet_mask" name="v4.subnet_mask"
component={renderInputField} control={control}
type="text" rules={{
className="form-control" validate: {
placeholder={t(ipv4placeholders.subnet_mask)} required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
validate={[validateRequired, validateGatewaySubnetMask]} subnet: validateGatewaySubnetMask,
disabled={!isInterfaceIncludesIpv4} },
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="v4_subnet_mask"
label={t('dhcp_form_subnet_input')}
placeholder={t(ipv4placeholders.subnet_mask)}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv4}
/>
)}
/> />
</div> </div>
</div> </div>
<div className="col-lg-6"> <div className="col-lg-6">
<div className="form__group form__group--settings"> <div className="form__group mb-0">
<div className="row"> <div className="row">
<div className="col-12"> <div className="col-12">
<label>{t('dhcp_form_range_title')}</label> <label>{t('dhcp_form_range_title')}</label>
</div> </div>
<div className="col"> <div className="col">
<Field <Controller
name="v4.range_start" name="v4.range_start"
component={renderInputField} control={control}
type="text" rules={{
className="form-control" validate: {
placeholder={t(ipv4placeholders.range_start)} ipv4: validateIpv4,
validate={[validateIpv4, validateIpForGatewaySubnetMask]} gateway: validateIpForGatewaySubnetMask,
disabled={!isInterfaceIncludesIpv4} },
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="v4_range_start"
placeholder={t(ipv4placeholders.range_start)}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv4}
/>
)}
/> />
</div> </div>
<div className="col"> <div className="col">
<Field <Controller
name="v4.range_end" name="v4.range_end"
component={renderInputField} control={control}
type="text" rules={{
className="form-control" validate: {
placeholder={t(ipv4placeholders.range_end)} ipv4: validateIpv4,
validate={[validateIpv4, validateIpv4RangeEnd, validateIpForGatewaySubnetMask]} rangeEnd: validateIpv4RangeEnd,
disabled={!isInterfaceIncludesIpv4} gateway: validateIpForGatewaySubnetMask,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="v4_range_end"
placeholder={t(ipv4placeholders.range_end)}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv4}
/>
)}
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label>{t('dhcp_form_lease_title')}</label> <Controller
<Field
name="v4.lease_duration" name="v4.lease_duration"
component={renderInputField} control={control}
type="number" rules={{
className="form-control" validate: {
placeholder={t(ipv4placeholders.lease_duration)} required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
validate={validateRequired} },
normalize={toNumber} }}
min={1} render={({ field, fieldState }) => (
max={UINT32_RANGE.MAX} <Input
disabled={!isInterfaceIncludesIpv4} {...field}
type="number"
data-testid="v4_lease_duration"
label={t('dhcp_form_lease_title')}
placeholder={t(ipv4placeholders.lease_duration)}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv4}
min={1}
max={UINT32_RANGE.MAX}
value={field.value ?? ''}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="btn-list"> <div className="btn-list">
<button type="submit" className="btn btn-success btn-standard" disabled={invalid}> <button
data-testid="v4_save"
type="submit"
className="btn btn-success btn-standard"
disabled={isDisabled}>
{t('save_config')} {t('save_config')}
</button> </button>
</div> </div>
@@ -158,9 +204,4 @@ const FormDHCPv4 = ({ handleSubmit, submitting, processingConfig, ipv4placeholde
); );
}; };
export default reduxForm< export default FormDHCPv4;
Record<string, any>,
Omit<FormDHCPv4Props, 'submitting' | 'handleSubmit' | 'reset' | 'change'>
>({
form: FORM_NAME.DHCPv4,
})(FormDHCPv4);

View File

@@ -1,93 +1,92 @@
import React, { useCallback } from 'react'; import React, { useMemo } from 'react';
import { shallowEqual, useSelector } from 'react-redux'; import { Controller, useFormContext } from 'react-hook-form';
import { Field, reduxForm } from 'redux-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { renderInputField, toNumber } from '../../../helpers/form'; import { UINT32_RANGE } from '../../../helpers/constants';
import { FORM_NAME, UINT32_RANGE } from '../../../helpers/constants';
import { validateIpv6, validateRequiredValue } from '../../../helpers/validators'; import { validateIpv6, validateRequiredValue } from '../../../helpers/validators';
import { RootState } from '../../../initialState'; import { DhcpFormValues } from '.';
import { Input } from '../../ui/Controls/Input';
import { toNumber } from '../../../helpers/form';
interface FormDHCPv6Props { type FormDHCPv6Props = {
handleSubmit: (...args: unknown[]) => string;
submitting: boolean;
initialValues: {
v6?: any;
};
change: (field: string, value: any) => void;
reset: () => void;
processingConfig?: boolean; processingConfig?: boolean;
ipv6placeholders?: { ipv6placeholders?: {
range_start: string; range_start: string;
range_end: string; range_end: string;
lease_duration: string; lease_duration: string;
}; };
} interfaces: any;
onSubmit?: (data: DhcpFormValues) => Promise<void> | void;
};
const FormDHCPv6 = ({ handleSubmit, submitting, processingConfig, ipv6placeholders }: FormDHCPv6Props) => { const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }: FormDHCPv6Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const {
handleSubmit,
formState: { isSubmitting, isValid },
control,
watch,
} = useFormContext<DhcpFormValues>();
const dhcp = useSelector((state: RootState) => state.form[FORM_NAME.DHCPv6], shallowEqual); const interfaceName = watch('interface_name');
const isInterfaceIncludesIpv6 = interfaces?.[interfaceName]?.ipv6_addresses;
const interfaces = useSelector((state: RootState) => state.form[FORM_NAME.DHCP_INTERFACES], shallowEqual); const formValues = watch('v6');
const interface_name = interfaces?.values?.interface_name; const isEmptyConfig = !Object.values(formValues || {}).some(Boolean);
const isInterfaceIncludesIpv6 = useSelector( const isDisabled = useMemo(() => {
(state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv6_addresses, return isSubmitting || !isValid || processingConfig || !isInterfaceIncludesIpv6 || isEmptyConfig;
); }, [isSubmitting, isValid, processingConfig, isInterfaceIncludesIpv6, isEmptyConfig]);
const isEmptyConfig = !Object.values(dhcp?.values?.v6 ?? {}).some(Boolean);
const invalid =
dhcp?.syncErrors ||
interfaces?.syncErrors ||
!isInterfaceIncludesIpv6 ||
isEmptyConfig ||
submitting ||
processingConfig;
const validateRequired = useCallback(
(value) => {
if (isEmptyConfig) {
return undefined;
}
return validateRequiredValue(value);
},
[isEmptyConfig],
);
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="row"> <div className="row">
<div className="col-lg-6"> <div className="col-lg-6">
<div className="form__group form__group--settings"> <div className="form__group mb-0">
<div className="row"> <div className="row">
<div className="col-12"> <div className="col-12">
<label>{t('dhcp_form_range_title')}</label> <label>{t('dhcp_form_range_title')}</label>
</div> </div>
<div className="col"> <div className="col">
<Field <Controller
name="v6.range_start" name="v6.range_start"
component={renderInputField} control={control}
type="text" rules={{
className="form-control" validate: isInterfaceIncludesIpv6
placeholder={t(ipv6placeholders.range_start)} ? {
validate={[validateIpv6, validateRequired]} ipv6: validateIpv6,
disabled={!isInterfaceIncludesIpv6} required: validateRequiredValue,
}
: undefined,
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="v6_range_start"
placeholder={t(ipv6placeholders.range_start)}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv6}
/>
)}
/> />
</div> </div>
<div className="col"> <div className="col">
<Field <Controller
name="v6.range_end" name="v6.range_end"
component="input" control={control}
type="text" render={({ field, fieldState }) => (
className="form-control disabled cursor--not-allowed" <Input
placeholder={t(ipv6placeholders.range_end)} {...field}
value={t(ipv6placeholders.range_end)} type="text"
disabled data-testid="v6_range_end"
placeholder={t(ipv6placeholders.range_end)}
error={fieldState.error?.message}
disabled
/>
)}
/> />
</div> </div>
</div> </div>
@@ -97,25 +96,43 @@ const FormDHCPv6 = ({ handleSubmit, submitting, processingConfig, ipv6placeholde
<div className="row"> <div className="row">
<div className="col-lg-6 form__group form__group--settings"> <div className="col-lg-6 form__group form__group--settings">
<label>{t('dhcp_form_lease_title')}</label> <Controller
<Field
name="v6.lease_duration" name="v6.lease_duration"
component={renderInputField} control={control}
type="number" rules={{
className="form-control" validate: isInterfaceIncludesIpv6
placeholder={t(ipv6placeholders.lease_duration)} ? {
validate={validateRequired} required: validateRequiredValue,
normalizeOnBlur={toNumber} }
min={1} : undefined,
max={UINT32_RANGE.MAX} }}
disabled={!isInterfaceIncludesIpv6} render={({ field, fieldState }) => (
<Input
{...field}
type="number"
data-testid="v6_lease_duration"
label={t('dhcp_form_lease_title')}
placeholder={t(ipv6placeholders.lease_duration)}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv6}
min={1}
max={UINT32_RANGE.MAX}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
</div> </div>
<div className="btn-list"> <div className="btn-list">
<button type="submit" className="btn btn-success btn-standard" disabled={invalid}> <button
data-testid="v6_save"
type="submit"
className="btn btn-success btn-standard"
disabled={isDisabled}>
{t('save_config')} {t('save_config')}
</button> </button>
</div> </div>
@@ -123,9 +140,4 @@ const FormDHCPv6 = ({ handleSubmit, submitting, processingConfig, ipv6placeholde
); );
}; };
export default reduxForm< export default FormDHCPv6;
Record<string, any>,
Omit<FormDHCPv6Props, 'handleSubmit' | 'change' | 'submitting' | 'reset'>
>({
form: FORM_NAME.DHCPv6,
})(FormDHCPv6);

View File

@@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import { shallowEqual, useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useFormContext } from 'react-hook-form';
import { Field, reduxForm } from 'redux-form';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { renderSelectField } from '../../../helpers/form';
import { validateRequiredValue } from '../../../helpers/validators'; import { validateRequiredValue } from '../../../helpers/validators';
import { FORM_NAME } from '../../../helpers/constants';
import { RootState } from '../../../initialState'; import { RootState } from '../../../initialState';
import { DhcpFormValues } from '.';
const renderInterfaces = (interfaces: any) => const renderInterfaces = (interfaces: any) =>
Object.keys(interfaces).map((item) => { Object.keys(interfaces).map((item) => {
@@ -47,13 +45,13 @@ const getInterfaceValues = ({ gateway_ip, hardware_address, ip_addresses }: any)
}, },
]; ];
interface renderInterfaceValuesProps { interface RenderInterfaceValuesProps {
gateway_ip: string; gateway_ip: string;
hardware_address: string; hardware_address: string;
ip_addresses: string[]; ip_addresses: string[];
} }
const renderInterfaceValues = ({ gateway_ip, hardware_address, ip_addresses }: renderInterfaceValuesProps) => ( const renderInterfaceValues = ({ gateway_ip, hardware_address, ip_addresses }: RenderInterfaceValuesProps) => (
<div className="d-flex align-items-end dhcp__interfaces-info"> <div className="d-flex align-items-end dhcp__interfaces-info">
<ul className="list-unstyled m-0"> <ul className="list-unstyled m-0">
{getInterfaceValues({ {getInterfaceValues({
@@ -77,11 +75,15 @@ const renderInterfaceValues = ({ gateway_ip, hardware_address, ip_addresses }: r
const Interfaces = () => { const Interfaces = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const {
register,
watch,
formState: { errors },
} = useFormContext<DhcpFormValues>();
const { processingInterfaces, interfaces, enabled } = useSelector((store: RootState) => store.dhcp, shallowEqual); const { processingInterfaces, interfaces, enabled } = useSelector((store: RootState) => store.dhcp);
const interface_name = const interface_name = watch('interface_name');
useSelector((store: RootState) => store.form[FORM_NAME.DHCP_INTERFACES]?.values?.interface_name);
if (processingInterfaces || !interfaces) { if (processingInterfaces || !interfaces) {
return null; return null;
@@ -92,27 +94,34 @@ const Interfaces = () => {
return ( return (
<div className="row dhcp__interfaces"> <div className="row dhcp__interfaces">
<div className="col col__dhcp"> <div className="col col__dhcp">
<Field <label htmlFor="interface_name" className="form__label">
name="interface_name" {t('dhcp_interface_select')}
component={renderSelectField} </label>
<select
id="interface_name"
data-testid="interface_name"
className="form-control custom-select pl-4 col-md" className="form-control custom-select pl-4 col-md"
validate={[validateRequiredValue]} disabled={enabled}
label="dhcp_interface_select"> {...register('interface_name', {
validate: validateRequiredValue,
})}>
<option value="" disabled={enabled}> <option value="" disabled={enabled}>
{t('dhcp_interface_select')} {t('dhcp_interface_select')}
</option> </option>
{renderInterfaces(interfaces)} {renderInterfaces(interfaces)}
</Field> </select>
{errors.interface_name && (
<div className="form__message form__message--error">{t(errors.interface_name.message)}</div>
)}
</div> </div>
{interfaceValue && renderInterfaceValues({ {interfaceValue &&
gateway_ip: interfaceValue.gateway_ip, renderInterfaceValues({
hardware_address: interfaceValue.hardware_address, gateway_ip: interfaceValue.gateway_ip,
ip_addresses: interfaceValue.ip_addresses hardware_address: interfaceValue.hardware_address,
})} ip_addresses: interfaceValue.ip_addresses,
})}
</div> </div>
); );
}; };
export default reduxForm({ export default Interfaces;
form: FORM_NAME.DHCP_INTERFACES,
})(Interfaces);

View File

@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Field, reduxForm } from 'redux-form';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { useDispatch, useSelector, shallowEqual } from 'react-redux'; import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import { renderInputField, normalizeMac } from '../../../../helpers/form'; import { normalizeMac } from '../../../../helpers/form';
import { import {
validateIpv4, validateIpv4,
validateMac, validateMac,
@@ -12,12 +11,12 @@ import {
validateIpv4InCidr, validateIpv4InCidr,
validateIpGateway, validateIpGateway,
} from '../../../../helpers/validators'; } from '../../../../helpers/validators';
import { FORM_NAME } from '../../../../helpers/constants';
import { toggleLeaseModal } from '../../../../actions'; import { toggleLeaseModal } from '../../../../actions';
import { RootState } from '../../../../initialState'; import { RootState } from '../../../../initialState';
import { Input } from '../../../ui/Controls/Input';
interface FormStaticLeaseProps { type Props = {
initialValues?: { initialValues?: {
mac?: string; mac?: string;
ip?: string; ip?: string;
@@ -25,63 +24,91 @@ interface FormStaticLeaseProps {
cidr?: string; cidr?: string;
gatewayIp?: string; gatewayIp?: string;
}; };
pristine: boolean;
handleSubmit: (...args: unknown[]) => string;
reset: () => void;
submitting: boolean;
processingAdding?: boolean; processingAdding?: boolean;
cidr?: string; cidr?: string;
isEdit?: boolean; isEdit?: boolean;
} onSubmit: (data: any) => void;
};
const Form = ({ handleSubmit, reset, pristine, submitting, processingAdding, cidr, isEdit }: FormStaticLeaseProps) => { export const Form = ({ initialValues, processingAdding, cidr, isEdit, onSubmit }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const dynamicLease = useSelector((store: RootState) => store.dhcp.leaseModalConfig, shallowEqual); const dynamicLease = useSelector((store: RootState) => store.dhcp.leaseModalConfig, shallowEqual);
const {
handleSubmit,
control,
reset,
formState: { isSubmitting, isDirty },
} = useForm({
defaultValues: initialValues,
mode: 'onBlur',
});
const onClick = () => { const onClick = () => {
reset(); reset();
dispatch(toggleLeaseModal()); dispatch(toggleLeaseModal());
}; };
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="modal-body"> <div className="modal-body">
<div className="form__group"> <div className="form__group">
<Field <Controller
id="mac"
name="mac" name="mac"
component={renderInputField} control={control}
type="text" rules={{ validate: { required: validateRequiredValue, mac: validateMac } }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('form_enter_mac')} <Input
normalize={normalizeMac} {...field}
validate={[validateRequiredValue, validateMac]} type="text"
disabled={isEdit} data-testid="static_lease_mac"
placeholder={t('form_enter_mac')}
disabled={isEdit}
error={fieldState.error?.message}
onChange={(e) => field.onChange(normalizeMac(e.target.value))}
/>
)}
/> />
</div> </div>
<div className="form__group"> <div className="form__group">
<Field <Controller
id="ip"
name="ip" name="ip"
component={renderInputField} control={control}
type="text" rules={{
className="form-control" validate: {
placeholder={t('form_enter_subnet_ip', { cidr })} required: validateRequiredValue,
validate={[validateRequiredValue, validateIpv4, validateIpv4InCidr, validateIpGateway]} ipv4: validateIpv4,
inCidr: validateIpv4InCidr,
gateway: validateIpGateway,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="static_lease_ip"
error={fieldState.error?.message}
placeholder={t('form_enter_subnet_ip', { cidr })}
/>
)}
/> />
</div> </div>
<div className="form__group"> <div className="form__group">
<Field <Controller
id="hostname"
name="hostname" name="hostname"
component={renderInputField} control={control}
type="text" render={({ field, fieldState }) => (
className="form-control" <Input
placeholder={t('form_enter_hostname')} {...field}
type="text"
data-testid="static_lease_hostname"
error={fieldState.error?.message}
placeholder={t('form_enter_hostname')}
/>
)}
/> />
</div> </div>
</div> </div>
@@ -90,16 +117,18 @@ const Form = ({ handleSubmit, reset, pristine, submitting, processingAdding, cid
<div className="btn-list"> <div className="btn-list">
<button <button
type="button" type="button"
data-testid="static_lease_cancel"
className="btn btn-secondary btn-standard" className="btn btn-secondary btn-standard"
disabled={submitting} disabled={isSubmitting}
onClick={onClick}> onClick={onClick}>
<Trans>cancel_btn</Trans> <Trans>cancel_btn</Trans>
</button> </button>
<button <button
type="submit" type="submit"
data-testid="static_lease_save"
className="btn btn-success btn-standard" className="btn btn-success btn-standard"
disabled={submitting || processingAdding || (pristine && !dynamicLease)}> disabled={isSubmitting || processingAdding || (!isDirty && !dynamicLease)}>
<Trans>save_btn</Trans> <Trans>save_btn</Trans>
</button> </button>
</div> </div>
@@ -107,8 +136,3 @@ const Form = ({ handleSubmit, reset, pristine, submitting, processingAdding, cid
</form> </form>
); );
}; };
export default reduxForm<
Record<string, any>,
Omit<FormStaticLeaseProps, 'submitting' | 'handleSubmit' | 'reset' | 'pristine'>
>({ form: FORM_NAME.LEASE })(Form);

View File

@@ -4,7 +4,7 @@ import { Trans, withTranslation } from 'react-i18next';
import ReactModal from 'react-modal'; import ReactModal from 'react-modal';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import Form from './Form'; import { Form } from './Form';
import { toggleLeaseModal } from '../../../../actions'; import { toggleLeaseModal } from '../../../../actions';
import { MODAL_TYPE } from '../../../../helpers/constants'; import { MODAL_TYPE } from '../../../../helpers/constants';

View File

@@ -4,8 +4,8 @@ import { Trans, useTranslation } from 'react-i18next';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import classNames from 'classnames'; import classNames from 'classnames';
import { destroy } from 'redux-form'; import { FormProvider, useForm } from 'react-hook-form';
import { DHCP_DESCRIPTION_PLACEHOLDERS, DHCP_FORM_NAMES, STATUS_RESPONSE, FORM_NAME } from '../../../helpers/constants'; import { DHCP_DESCRIPTION_PLACEHOLDERS, STATUS_RESPONSE } from '../../../helpers/constants';
import Leases from './Leases'; import Leases from './Leases';
@@ -40,6 +40,55 @@ import {
import './index.css'; import './index.css';
import { RootState } from '../../../initialState'; import { RootState } from '../../../initialState';
type IPv4FormValues = {
gateway_ip?: string;
subnet_mask?: string;
range_start?: string;
range_end?: string;
lease_duration?: number;
}
type IPv6FormValues = {
range_start?: string;
range_end?: string;
lease_duration?: number;
}
const getDefaultV4Values = (v4: IPv4FormValues) => {
const emptyForm = Object.entries(v4).every(
([key, value]) => key === 'lease_duration' || value === ''
);
if (emptyForm) {
return {
...v4,
lease_duration: undefined,
}
}
return v4;
}
export type DhcpFormValues = {
v4?: IPv4FormValues;
v6?: IPv6FormValues;
interface_name?: string;
};
const DEFAULT_V4_VALUES = {
gateway_ip: '',
subnet_mask: '',
range_start: '',
range_end: '',
lease_duration: undefined,
};
const DEFAULT_V6_VALUES = {
range_start: '',
range_end: '',
lease_duration: undefined,
};
const Dhcp = () => { const Dhcp = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -65,12 +114,21 @@ const Dhcp = () => {
modalType, modalType,
} = useSelector((state: RootState) => state.dhcp, shallowEqual); } = useSelector((state: RootState) => state.dhcp, shallowEqual);
const interface_name = const methods = useForm<DhcpFormValues>({
useSelector((state: RootState) => state.form[FORM_NAME.DHCP_INTERFACES]?.values?.interface_name); mode: 'onBlur',
const isInterfaceIncludesIpv4 = defaultValues: {
useSelector((state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv4_addresses); v4: getDefaultV4Values(v4),
v6,
interface_name: interfaceName || '',
},
});
const { watch, reset } = methods;
const dhcp = useSelector((state: RootState) => state.form[FORM_NAME.DHCPv4], shallowEqual); const interface_name = watch('interface_name');
const isInterfaceIncludesIpv4 = useSelector(
(state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv4_addresses,
);
const ipv4Config = watch('v4');
const [ipv4placeholders, setIpv4Placeholders] = useState(DHCP_DESCRIPTION_PLACEHOLDERS.ipv4); const [ipv4placeholders, setIpv4Placeholders] = useState(DHCP_DESCRIPTION_PLACEHOLDERS.ipv4);
const [ipv6placeholders, setIpv6Placeholders] = useState(DHCP_DESCRIPTION_PLACEHOLDERS.ipv6); const [ipv6placeholders, setIpv6Placeholders] = useState(DHCP_DESCRIPTION_PLACEHOLDERS.ipv6);
@@ -85,6 +143,22 @@ const Dhcp = () => {
} }
}, [dhcp_available]); }, [dhcp_available]);
useEffect(() => {
if (v4 || v6 || interfaceName) {
reset({
v4: {
...DEFAULT_V4_VALUES,
...getDefaultV4Values(v4),
},
v6: {
...DEFAULT_V6_VALUES,
...v6,
},
interface_name: interfaceName || '',
});
}
}, [v4, v6, interfaceName, reset]);
useEffect(() => { useEffect(() => {
const [ipv4] = interfaces?.[interface_name]?.ipv4_addresses ?? []; const [ipv4] = interfaces?.[interface_name]?.ipv4_addresses ?? [];
const [ipv6] = interfaces?.[interface_name]?.ipv6_addresses ?? []; const [ipv6] = interfaces?.[interface_name]?.ipv6_addresses ?? [];
@@ -103,13 +177,17 @@ const Dhcp = () => {
const clear = () => { const clear = () => {
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
if (window.confirm(t('dhcp_reset'))) { if (window.confirm(t('dhcp_reset'))) {
Object.values(DHCP_FORM_NAMES).forEach((formName: any) => dispatch(destroy(formName))); reset({
v4: DEFAULT_V4_VALUES,
v6: DEFAULT_V6_VALUES,
interface_name: '',
});
dispatch(resetDhcp()); dispatch(resetDhcp());
dispatch(getDhcpStatus()); dispatch(getDhcpStatus());
} }
}; };
const handleSubmit = (values: any) => { const handleSubmit = (values: DhcpFormValues) => {
dispatch( dispatch(
setDhcpConfig({ setDhcpConfig({
interface_name, interface_name,
@@ -130,12 +208,7 @@ const Dhcp = () => {
const enteredSomeValue = enteredSomeV4Value || enteredSomeV6Value || interfaceName; const enteredSomeValue = enteredSomeV4Value || enteredSomeV6Value || interfaceName;
const getToggleDhcpButton = () => { const getToggleDhcpButton = () => {
const filledConfig = const filledConfig = interface_name && (Object.values(v4).every(Boolean) || Object.values(v6).every(Boolean));
interface_name &&
(Object.values(v4)
.every(Boolean) ||
Object.values(v6).every(Boolean));
const className = classNames('btn btn-sm', { const className = classNames('btn btn-sm', {
'btn-gray': enabled, 'btn-gray': enabled,
@@ -173,9 +246,6 @@ const Dhcp = () => {
const toggleModal = () => dispatch(toggleLeaseModal()); const toggleModal = () => dispatch(toggleLeaseModal());
const initialV4 = enteredSomeV4Value ? v4 : {};
const initialV6 = enteredSomeV6Value ? v6 : {};
if (processing || processingInterfaces) { if (processing || processingInterfaces) {
return <Loading />; return <Loading />;
} }
@@ -196,19 +266,13 @@ const Dhcp = () => {
const toggleDhcpButton = getToggleDhcpButton(); const toggleDhcpButton = getToggleDhcpButton();
const inputtedIPv4values = dhcp?.values?.v4?.gateway_ip && dhcp?.values?.v4?.subnet_mask; const inputtedIPv4values = ipv4Config.gateway_ip && ipv4Config.subnet_mask;
const isEmptyConfig = !Object.values(dhcp?.values?.v4 ?? {}).some(Boolean); const isEmptyConfig = !Object.values(ipv4Config).some(Boolean);
const disabledLeasesButton = Boolean( const disabledLeasesButton = Boolean(
dhcp?.syncErrors || !isInterfaceIncludesIpv4 || isEmptyConfig || processingConfig || !inputtedIPv4values,
!isInterfaceIncludesIpv4 ||
isEmptyConfig ||
processingConfig ||
!inputtedIPv4values,
); );
const cidr = inputtedIPv4values const cidr = inputtedIPv4values ? `${ipv4Config.gateway_ip}/${subnetMaskToBitMask(ipv4Config.subnet_mask)}` : '';
? `${dhcp?.values?.v4?.gateway_ip}/${subnetMaskToBitMask(dhcp?.values?.v4?.subnet_mask)}`
: '';
return ( return (
<> <>
@@ -246,29 +310,30 @@ const Dhcp = () => {
</div> </div>
)} )}
<Interfaces initialValues={{ interface_name: interfaceName }} /> <FormProvider {...methods}>
<Interfaces />
<Card title={t('dhcp_ipv4_settings')} bodyType="card-body box-body--settings">
<div>
<FormDHCPv4
onSubmit={handleSubmit}
processingConfig={processingConfig}
ipv4placeholders={ipv4placeholders}
interfaces={interfaces}
/>
</div>
</Card>
<Card title={t('dhcp_ipv6_settings')} bodyType="card-body box-body--settings">
<div>
<FormDHCPv6
onSubmit={handleSubmit}
processingConfig={processingConfig}
ipv6placeholders={ipv6placeholders}
interfaces={interfaces}
/>
</div>
</Card>
</FormProvider>
<Card title={t('dhcp_ipv4_settings')} bodyType="card-body box-body--settings">
<div>
<FormDHCPv4
onSubmit={handleSubmit}
initialValues={{ v4: initialV4 }}
processingConfig={processingConfig}
ipv4placeholders={ipv4placeholders}
/>
</div>
</Card>
<Card title={t('dhcp_ipv6_settings')} bodyType="card-body box-body--settings">
<div>
<FormDHCPv6
onSubmit={handleSubmit}
initialValues={{ v6: initialV6 }}
processingConfig={processingConfig}
ipv6placeholders={ipv6placeholders}
/>
</div>
</Card>
{enabled && ( {enabled && (
<Card title={t('dhcp_leases')} bodyType="card-body box-body--settings"> <Card title={t('dhcp_leases')} bodyType="card-body box-body--settings">
<div className="row"> <div className="row">
@@ -290,7 +355,7 @@ const Dhcp = () => {
processingDeleting={processingDeleting} processingDeleting={processingDeleting}
processingUpdating={processingUpdating} processingUpdating={processingUpdating}
cidr={cidr} cidr={cidr}
gatewayIp={dhcp?.values?.v4?.gateway_ip} gatewayIp={ipv4Config.gateway_ip}
/> />
<div className="btn-list mt-2"> <div className="btn-list mt-2">

View File

@@ -1,118 +1,140 @@
import React from 'react'; import React, { ReactNode } from 'react';
import { connect } from 'react-redux'; import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { Field, reduxForm, formValueSelector } from 'redux-form'; import i18next from 'i18next';
import { Trans, withTranslation } from 'react-i18next'; import { CLIENT_ID_LINK } from '../../../../helpers/constants';
import flow from 'lodash/flow'; import { removeEmptyLines, trimMultilineString } from '../../../../helpers/helpers';
import { Textarea } from '../../../ui/Controls/Textarea';
import { renderTextareaField } from '../../../../helpers/form'; type FormData = {
import { trimMultilineString, removeEmptyLines } from '../../../../helpers/helpers'; allowed_clients: string;
import { CLIENT_ID_LINK, FORM_NAME } from '../../../../helpers/constants'; disallowed_clients: string;
blocked_hosts: string;
};
const fields = [ const fields: {
id: keyof FormData;
title: string;
subtitle: ReactNode;
normalizeOnBlur: (value: string) => string;
}[] = [
{ {
id: 'allowed_clients', id: 'allowed_clients',
title: 'access_allowed_title', title: i18next.t('access_allowed_title'),
subtitle: 'access_allowed_desc', subtitle: (
<Trans
components={{
a: <a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer" />,
}}>
access_allowed_desc
</Trans>
),
normalizeOnBlur: removeEmptyLines, normalizeOnBlur: removeEmptyLines,
}, },
{ {
id: 'disallowed_clients', id: 'disallowed_clients',
title: 'access_disallowed_title', title: i18next.t('access_disallowed_title'),
subtitle: 'access_disallowed_desc', subtitle: (
<Trans
components={{
a: <a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer" />,
}}>
access_disallowed_desc
</Trans>
),
normalizeOnBlur: trimMultilineString, normalizeOnBlur: trimMultilineString,
}, },
{ {
id: 'blocked_hosts', id: 'blocked_hosts',
title: 'access_blocked_title', title: i18next.t('access_blocked_title'),
subtitle: 'access_blocked_desc', subtitle: i18next.t('access_blocked_desc'),
normalizeOnBlur: removeEmptyLines, normalizeOnBlur: removeEmptyLines,
}, },
]; ];
interface FormProps { type FormProps = {
handleSubmit: (...args: unknown[]) => string; initialValues?: {
submitting: boolean; allowed_clients?: string;
invalid: boolean; disallowed_clients?: string;
initialValues: object; blocked_hosts?: string;
};
onSubmit: (data: FormData) => void;
processingSet: boolean; processingSet: boolean;
t: (...args: unknown[]) => string; };
textarea?: boolean;
allowedClients?: string;
}
interface renderFieldProps { const Form = ({ initialValues, onSubmit, processingSet }: FormProps) => {
id?: string; const { t } = useTranslation();
title?: string;
subtitle?: string;
disabled?: boolean;
processingSet?: boolean;
normalizeOnBlur?: (...args: unknown[]) => unknown;
}
let Form = (props: FormProps) => { const {
const { allowedClients, handleSubmit, submitting, invalid, processingSet } = props; control,
handleSubmit,
watch,
formState: { isSubmitting, isDirty },
} = useForm<FormData>({
mode: 'onBlur',
defaultValues: {
allowed_clients: initialValues?.allowed_clients || '',
disallowed_clients: initialValues?.disallowed_clients || '',
blocked_hosts: initialValues?.blocked_hosts || '',
},
});
const allowedClients = watch('allowed_clients');
const renderField = ({ const renderField = ({
id, id,
title, title,
subtitle, subtitle,
disabled = false,
processingSet,
normalizeOnBlur, normalizeOnBlur,
}: renderFieldProps) => ( }: {
<div key={id} className="form__group mb-5"> id: keyof FormData;
<label className="form__label form__label--with-desc" htmlFor={id}> title: string;
<Trans>{title}</Trans> subtitle: ReactNode;
normalizeOnBlur: (value: string) => string;
}) => {
const disabled = allowedClients && id === 'disallowed_clients';
{disabled && ( return (
<> <div key={id} className="form__group mb-5">
<span> </span>(<Trans>disabled</Trans>) <label className="form__label form__label--with-desc" htmlFor={id}>
</> {title}
)} {disabled && <>&nbsp;({t('disabled')})</>}
</label> </label>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">{subtitle}</div>
<Trans
components={{ <Controller
a: ( name={id}
<a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer"> control={control}
text render={({ field }) => (
</a> <Textarea
), {...field}
}}> id={id}
{subtitle} data-testid={id}
</Trans> disabled={disabled || processingSet}
onBlur={(e) => {
field.onChange(normalizeOnBlur(e.target.value));
}}
/>
)}
/>
</div> </div>
);
<Field };
id={id}
name={id}
component={renderTextareaField}
type="text"
className="form-control form-control--textarea font-monospace"
disabled={disabled || processingSet}
normalizeOnBlur={normalizeOnBlur}
/>
</div>
);
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmit)}>
{fields.map((f) => { {fields.map((f) => renderField(f))}
return renderField({
...f,
disabled: allowedClients && f.id === 'disallowed_clients' || false
});
})}
<div className="card-actions"> <div className="card-actions">
<div className="btn-list"> <div className="btn-list">
<button <button
type="submit" type="submit"
data-testid="access_save"
className="btn btn-success btn-standard" className="btn btn-success btn-standard"
disabled={submitting || invalid || processingSet}> disabled={isSubmitting || !isDirty || processingSet}>
<Trans>save_config</Trans> {t('save_config')}
</button> </button>
</div> </div>
</div> </div>
@@ -120,18 +142,4 @@ let Form = (props: FormProps) => {
); );
}; };
const selector = formValueSelector(FORM_NAME.ACCESS); export default Form;
Form = connect((state) => {
const allowedClients = selector(state, 'allowed_clients');
return {
allowedClients,
};
})(Form);
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.ACCESS,
}),
])(Form);

View File

@@ -1,52 +1,72 @@
import React from 'react'; import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Field, reduxForm } from 'redux-form'; import i18next from 'i18next';
import { Trans, useTranslation } from 'react-i18next';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { renderInputField, toNumber, CheckboxField } from '../../../../helpers/form';
import { CACHE_CONFIG_FIELDS, FORM_NAME, UINT32_RANGE } from '../../../../helpers/constants';
import { replaceZeroWithEmptyString } from '../../../../helpers/helpers';
import { clearDnsCache } from '../../../../actions/dnsConfig'; import { clearDnsCache } from '../../../../actions/dnsConfig';
import { CACHE_CONFIG_FIELDS, UINT32_RANGE } from '../../../../helpers/constants';
import { replaceZeroWithEmptyString } from '../../../../helpers/helpers';
import { RootState } from '../../../../initialState'; import { RootState } from '../../../../initialState';
import { Checkbox } from '../../../ui/Controls/Checkbox';
const INPUTS_FIELDS = [ const INPUTS_FIELDS = [
{ {
name: CACHE_CONFIG_FIELDS.cache_size, name: CACHE_CONFIG_FIELDS.cache_size,
title: 'cache_size', title: i18next.t('cache_size'),
description: 'cache_size_desc', description: i18next.t('cache_size_desc'),
placeholder: 'enter_cache_size', placeholder: i18next.t('enter_cache_size'),
}, },
{ {
name: CACHE_CONFIG_FIELDS.cache_ttl_min, name: CACHE_CONFIG_FIELDS.cache_ttl_min,
title: 'cache_ttl_min_override', title: i18next.t('cache_ttl_min_override'),
description: 'cache_ttl_min_override_desc', description: i18next.t('cache_ttl_min_override_desc'),
placeholder: 'enter_cache_ttl_min_override', placeholder: i18next.t('enter_cache_ttl_min_override'),
}, },
{ {
name: CACHE_CONFIG_FIELDS.cache_ttl_max, name: CACHE_CONFIG_FIELDS.cache_ttl_max,
title: 'cache_ttl_max_override', title: i18next.t('cache_ttl_max_override'),
description: 'cache_ttl_max_override_desc', description: i18next.t('cache_ttl_max_override_desc'),
placeholder: 'enter_cache_ttl_max_override', placeholder: i18next.t('enter_cache_ttl_max_override'),
}, },
]; ];
interface CacheFormProps { type FormData = {
handleSubmit: (...args: unknown[]) => string; cache_size: number;
submitting: boolean; cache_ttl_min: number;
invalid: boolean; cache_ttl_max: number;
} cache_optimistic: boolean;
};
const Form = ({ handleSubmit, submitting, invalid }: CacheFormProps) => { type CacheFormProps = {
initialValues?: Partial<FormData>;
onSubmit: (data: FormData) => void;
};
const Form = ({ initialValues, onSubmit }: CacheFormProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { processingSetConfig } = useSelector((state: RootState) => state.dnsConfig, shallowEqual); const { processingSetConfig } = useSelector((state: RootState) => state.dnsConfig);
const { cache_ttl_max, cache_ttl_min } = useSelector(
(state: RootState) => state.form[FORM_NAME.CACHE].values, const {
shallowEqual, register,
); handleSubmit,
watch,
control,
formState: { isSubmitting, isDirty },
} = useForm<FormData>({
mode: 'onBlur',
defaultValues: {
cache_size: initialValues?.cache_size || 0,
cache_ttl_min: initialValues?.cache_ttl_min || 0,
cache_ttl_max: initialValues?.cache_ttl_max || 0,
cache_optimistic: initialValues?.cache_optimistic || false,
},
});
const cache_ttl_min = watch('cache_ttl_min');
const cache_ttl_max = watch('cache_ttl_max');
const minExceedsMax = cache_ttl_min > 0 && cache_ttl_max > 0 && cache_ttl_min > cache_ttl_max; const minExceedsMax = cache_ttl_min > 0 && cache_ttl_max > 0 && cache_ttl_min > cache_ttl_max;
@@ -57,29 +77,30 @@ const Form = ({ handleSubmit, submitting, invalid }: CacheFormProps) => {
}; };
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="row"> <div className="row">
{INPUTS_FIELDS.map(({ name, title, description, placeholder }) => ( {INPUTS_FIELDS.map(({ name, title, description, placeholder }) => (
<div className="col-12" key={name}> <div className="col-12" key={name}>
<div className="col-12 col-md-7 p-0"> <div className="col-12 col-md-7 p-0">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label htmlFor={name} className="form__label form__label--with-desc"> <label htmlFor={name} className="form__label form__label--with-desc">
{t(title)} {title}
</label> </label>
<div className="form__desc form__desc--top">{t(description)}</div> <div className="form__desc form__desc--top">{description}</div>
<Field <input
name={name}
type="number" type="number"
component={renderInputField} data-testid={`dns_${name}`}
placeholder={t(placeholder)}
disabled={processingSetConfig}
className="form-control" className="form-control"
normalizeOnBlur={replaceZeroWithEmptyString} placeholder={placeholder}
normalize={toNumber} disabled={processingSetConfig}
min={0} min={0}
max={UINT32_RANGE.MAX} max={UINT32_RANGE.MAX}
{...register(name as keyof FormData, {
valueAsNumber: true,
setValueAs: (value) => replaceZeroWithEmptyString(value),
})}
/> />
</div> </div>
</div> </div>
@@ -91,13 +112,18 @@ const Form = ({ handleSubmit, submitting, invalid }: CacheFormProps) => {
<div className="row"> <div className="row">
<div className="col-12 col-md-7"> <div className="col-12 col-md-7">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name="cache_optimistic" name="cache_optimistic"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('cache_optimistic')} <Checkbox
disabled={processingSetConfig} {...field}
subtitle={t('cache_optimistic_desc')} data-testid="dns_cache_optimistic"
title={t('cache_optimistic')}
subtitle={t('cache_optimistic_desc')}
disabled={processingSetConfig}
/>
)}
/> />
</div> </div>
</div> </div>
@@ -105,19 +131,21 @@ const Form = ({ handleSubmit, submitting, invalid }: CacheFormProps) => {
<button <button
type="submit" type="submit"
data-testid="dns_save"
className="btn btn-success btn-standard btn-large" className="btn btn-success btn-standard btn-large"
disabled={submitting || invalid || processingSetConfig || minExceedsMax}> disabled={isSubmitting || !isDirty || processingSetConfig || minExceedsMax}>
<Trans>save_btn</Trans> {t('save_btn')}
</button> </button>
<button <button
type="button" type="button"
data-testid="dns_clear"
className="btn btn-outline-secondary btn-standard form__button" className="btn btn-outline-secondary btn-standard form__button"
onClick={handleClearCache}> onClick={handleClearCache}>
<Trans>clear_cache</Trans> {t('clear_cache')}
</button> </button>
</form> </form>
); );
}; };
export default reduxForm({ form: FORM_NAME.CACHE })(Form); export default Form;

View File

@@ -1,211 +1,279 @@
import React from 'react'; import React from 'react';
import { shallowEqual, useSelector } from 'react-redux'; import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Field, reduxForm } from 'redux-form'; import i18next from 'i18next';
import { Trans, useTranslation } from 'react-i18next'; import { validateIp, validateIpv4, validateIpv6, validateRequiredValue } from '../../../../helpers/validators';
import {
renderInputField,
renderRadioField,
renderTextareaField,
CheckboxField,
toNumber,
} from '../../../../helpers/form';
import {
validateIpv4,
validateIpv6,
validateRequiredValue,
validateIp,
validateIPv4Subnet,
validateIPv6Subnet,
} from '../../../../helpers/validators';
import { removeEmptyLines } from '../../../../helpers/helpers'; import { BLOCKING_MODES, UINT32_RANGE } from '../../../../helpers/constants';
import { BLOCKING_MODES, FORM_NAME, UINT32_RANGE } from '../../../../helpers/constants'; import { Checkbox } from '../../../ui/Controls/Checkbox';
import { RootState } from '../../../../initialState'; import { Input } from '../../../ui/Controls/Input';
import { toNumber } from '../../../../helpers/form';
import { Textarea } from '../../../ui/Controls/Textarea';
import { Radio } from '../../../ui/Controls/Radio';
const checkboxes = [ const checkboxes: {
name: 'dnssec_enabled' | 'disable_ipv6';
placeholder: string;
subtitle: string;
}[] = [
{ {
name: 'dnssec_enabled', name: 'dnssec_enabled',
placeholder: 'dnssec_enable', placeholder: i18next.t('dnssec_enable'),
subtitle: 'dnssec_enable_desc', subtitle: i18next.t('dnssec_enable_desc'),
}, },
{ {
name: 'disable_ipv6', name: 'disable_ipv6',
placeholder: 'disable_ipv6', placeholder: i18next.t('disable_ipv6'),
subtitle: 'disable_ipv6_desc', subtitle: i18next.t('disable_ipv6_desc'),
}, },
]; ];
const customIps = [ const customIps: {
name: 'blocking_ipv4' | 'blocking_ipv6';
label: string;
description: string;
validateIp: (value: string) => string;
}[] = [
{ {
description: 'blocking_ipv4_desc',
name: 'blocking_ipv4', name: 'blocking_ipv4',
label: i18next.t('blocking_ipv4'),
description: i18next.t('blocking_ipv4_desc'),
validateIp: validateIpv4, validateIp: validateIpv4,
}, },
{ {
description: 'blocking_ipv6_desc',
name: 'blocking_ipv6', name: 'blocking_ipv6',
label: i18next.t('blocking_ipv6'),
description: i18next.t('blocking_ipv6_desc'),
validateIp: validateIpv6, validateIp: validateIpv6,
}, },
]; ];
const getFields = (processing: any, t: any) => const blockingModeOptions = [
Object.values(BLOCKING_MODES) {
value: BLOCKING_MODES.default,
label: i18next.t('default'),
},
{
value: BLOCKING_MODES.refused,
label: i18next.t('refused'),
},
{
value: BLOCKING_MODES.nxdomain,
label: i18next.t('nxdomain'),
},
{
value: BLOCKING_MODES.null_ip,
label: i18next.t('null_ip'),
},
{
value: BLOCKING_MODES.custom_ip,
label: i18next.t('custom_ip'),
},
];
.map((mode: any) => ( const blockingModeDescriptions = [
<Field i18next.t(`blocking_mode_default`),
key={mode} i18next.t(`blocking_mode_refused`),
name="blocking_mode" i18next.t(`blocking_mode_nxdomain`),
type="radio" i18next.t(`blocking_mode_null_ip`),
component={renderRadioField} i18next.t(`blocking_mode_custom_ip`),
value={mode} ];
placeholder={t(mode)}
disabled={processing}
/>
));
interface ConfigFormProps { type FormData = {
handleSubmit: (...args: unknown[]) => string; ratelimit: number;
submitting: boolean; ratelimit_subnet_len_ipv4: number;
invalid: boolean; ratelimit_subnet_len_ipv6: number;
ratelimit_whitelist: string;
edns_cs_enabled: boolean;
edns_cs_use_custom: boolean;
edns_cs_custom_ip?: string;
dnssec_enabled: boolean;
disable_ipv6: boolean;
blocking_mode: string;
blocking_ipv4?: string;
blocking_ipv6?: string;
blocked_response_ttl: number;
};
type Props = {
processing?: boolean; processing?: boolean;
} initialValues?: Partial<FormData>;
onSubmit: (data: FormData) => void;
};
const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps) => { const Form = ({ processing, initialValues, onSubmit }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { blocking_mode, edns_cs_enabled, edns_cs_use_custom } = useSelector(
(state: RootState) => state.form[FORM_NAME.BLOCKING_MODE].values ?? {}, const {
shallowEqual, handleSubmit,
); watch,
control,
formState: { isSubmitting, isDirty },
} = useForm<FormData>({
mode: 'onBlur',
defaultValues: initialValues,
});
const blocking_mode = watch('blocking_mode');
const edns_cs_enabled = watch('edns_cs_enabled');
const edns_cs_use_custom = watch('edns_cs_use_custom');
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="row"> <div className="row">
<div className="col-12 col-md-7"> <div className="col-12 col-md-7">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label htmlFor="ratelimit" className="form__label form__label--with-desc"> <Controller
<Trans>rate_limit</Trans>
</label>
<div className="form__desc form__desc--top">
<Trans>rate_limit_desc</Trans>
</div>
<Field
name="ratelimit" name="ratelimit"
type="number" control={control}
component={renderInputField} rules={{ validate: validateRequiredValue }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('form_enter_rate_limit')} <Input
normalize={toNumber} {...field}
validate={validateRequiredValue} data-testid="dns_config_ratelimit"
min={UINT32_RANGE.MIN} type="number"
max={UINT32_RANGE.MAX} label={t('rate_limit')}
desc={t('rate_limit_desc')}
error={fieldState.error?.message}
min={UINT32_RANGE.MIN}
max={UINT32_RANGE.MAX}
disabled={processing}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
</div> </div>
<div className="col-12 col-md-7"> <div className="col-12 col-md-7">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label htmlFor="ratelimit_subnet_len_ipv4" className="form__label form__label--with-desc"> <Controller
<Trans>rate_limit_subnet_len_ipv4</Trans>
</label>
<div className="form__desc form__desc--top">
<Trans>rate_limit_subnet_len_ipv4_desc</Trans>
</div>
<Field
name="ratelimit_subnet_len_ipv4" name="ratelimit_subnet_len_ipv4"
type="number" control={control}
component={renderInputField} rules={{ validate: validateRequiredValue }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('form_enter_rate_limit_subnet_len')} <Input
normalize={toNumber} {...field}
validate={[validateRequiredValue, validateIPv4Subnet]} data-testid="dns_config_subnet_ipv4"
min={0} type="number"
max={32} label={t('rate_limit_subnet_len_ipv4')}
desc={t('rate_limit_subnet_len_ipv4_desc')}
error={fieldState.error?.message}
min={0}
max={32}
disabled={processing}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
</div> </div>
<div className="col-12 col-md-7"> <div className="col-12 col-md-7">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label htmlFor="ratelimit_subnet_len_ipv6" className="form__label form__label--with-desc"> <Controller
<Trans>rate_limit_subnet_len_ipv6</Trans>
</label>
<div className="form__desc form__desc--top">
<Trans>rate_limit_subnet_len_ipv6_desc</Trans>
</div>
<Field
name="ratelimit_subnet_len_ipv6" name="ratelimit_subnet_len_ipv6"
type="number" control={control}
component={renderInputField} rules={{ validate: validateRequiredValue }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('form_enter_rate_limit_subnet_len')} <Input
normalize={toNumber} {...field}
validate={[validateRequiredValue, validateIPv6Subnet]} data-testid="dns_config_subnet_ipv6"
min={0} type="number"
max={128} label={t('rate_limit_subnet_len_ipv6')}
desc={t('rate_limit_subnet_len_ipv6_desc')}
error={fieldState.error?.message}
min={0}
max={128}
disabled={processing}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
</div> </div>
<div className="col-12 col-md-7"> <div className="col-12 col-md-7">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label htmlFor="ratelimit_whitelist" className="form__label form__label--with-desc"> <Controller
<Trans>rate_limit_whitelist</Trans>
</label>
<div className="form__desc form__desc--top">
<Trans>rate_limit_whitelist_desc</Trans>
</div>
<Field
name="ratelimit_whitelist" name="ratelimit_whitelist"
component={renderTextareaField} control={control}
type="text" render={({ field, fieldState }) => (
className="form-control" <Textarea
placeholder={t('rate_limit_whitelist_placeholder')} {...field}
normalizeOnBlur={removeEmptyLines} data-testid="dns_config_subnet_ipv6"
label={t('rate_limit_whitelist')}
desc={t('rate_limit_whitelist_desc')}
error={fieldState.error?.message}
disabled={processing}
trimOnBlur
/>
)}
/> />
</div> </div>
</div> </div>
<div className="col-12"> <div className="col-12">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name="edns_cs_enabled" name="edns_cs_enabled"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('edns_enable')} <Checkbox
disabled={processing} {...field}
subtitle={t('edns_cs_desc')} data-testid="dns_config_edns_cs_enabled"
title={t('edns_enable')}
disabled={processing}
/>
)}
/> />
</div> </div>
</div> </div>
<div className="col-12 form__group form__group--inner"> <div className="col-12 form__group form__group--inner">
<div className="form__group "> <div className="form__group">
<Field <Controller
name="edns_cs_use_custom" name="edns_cs_use_custom"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('edns_use_custom_ip')} <Checkbox
disabled={processing || !edns_cs_enabled} {...field}
subtitle={t('edns_use_custom_ip_desc')} data-testid="dns_config_edns_use_custom_ip"
title={t('edns_use_custom_ip')}
disabled={processing || !edns_cs_enabled}
/>
)}
/> />
</div> </div>
{edns_cs_use_custom && ( {edns_cs_use_custom && (
<Field <Controller
name="edns_cs_custom_ip" name="edns_cs_custom_ip"
component={renderInputField} control={control}
className="form-control" rules={{
placeholder={t('form_enter_ip')} validate: {
validate={[validateIp, validateRequiredValue]} required: validateRequiredValue,
id: validateIp,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
data-testid="dns_config_edns_cs_custom_ip"
error={fieldState.error?.message}
disabled={processing || !edns_cs_enabled}
/>
)}
/> />
)} )}
</div> </div>
@@ -213,13 +281,18 @@ const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps
{checkboxes.map(({ name, placeholder, subtitle }) => ( {checkboxes.map(({ name, placeholder, subtitle }) => (
<div className="col-12" key={name}> <div className="col-12" key={name}>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name={name} name={name}
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t(placeholder)} <Checkbox
disabled={processing} {...field}
subtitle={t(subtitle)} data-testid={`dns_config_${name}`}
title={placeholder}
subtitle={subtitle}
disabled={processing}
/>
)}
/> />
</div> </div>
</div> </div>
@@ -227,42 +300,50 @@ const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps
<div className="col-12"> <div className="col-12">
<div className="form__group form__group--settings mb-4"> <div className="form__group form__group--settings mb-4">
<label className="form__label form__label--with-desc"> <label className="form__label form__label--with-desc">{t('blocking_mode')}</label>
<Trans>blocking_mode</Trans>
</label>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">
{Object.values(BLOCKING_MODES) {blockingModeDescriptions.map((desc: string) => (
<li key={desc}>{desc}</li>
.map((mode: any) => ( ))}
<li key={mode}>
<Trans>{`blocking_mode_${mode}`}</Trans>
</li>
))}
</div> </div>
<div className="custom-controls-stacked">{getFields(processing, t)}</div> <div className="custom-controls-stacked">
<Controller
name="blocking_mode"
control={control}
render={({ field }) => (
<Radio {...field} options={blockingModeOptions} disabled={processing} />
)}
/>
</div>
</div> </div>
</div> </div>
{blocking_mode === BLOCKING_MODES.custom_ip && ( {blocking_mode === BLOCKING_MODES.custom_ip && (
<> <>
{customIps.map(({ description, name, validateIp }) => ( {customIps.map(({ label, description, name, validateIp }) => (
<div className="col-12 col-sm-6" key={name}> <div className="col-12 col-sm-6" key={name}>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label className="form__label form__label--with-desc" htmlFor={name}> <Controller
<Trans>{name}</Trans>
</label>
<div className="form__desc form__desc--top">
<Trans>{description}</Trans>
</div>
<Field
name={name} name={name}
component={renderInputField} control={control}
className="form-control" rules={{
placeholder={t('form_enter_ip')} validate: {
validate={[validateIp, validateRequiredValue]} required: validateRequiredValue,
ip: validateIp,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
data-testid="dns_config_blocked_response_ttl"
type="text"
label={label}
desc={description}
error={fieldState.error?.message}
disabled={processing}
/>
)}
/> />
</div> </div>
</div> </div>
@@ -272,24 +353,27 @@ const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps
<div className="col-12 col-md-7"> <div className="col-12 col-md-7">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label htmlFor="blocked_response_ttl" className="form__label form__label--with-desc"> <Controller
<Trans>blocked_response_ttl</Trans>
</label>
<div className="form__desc form__desc--top">
<Trans>blocked_response_ttl_desc</Trans>
</div>
<Field
name="blocked_response_ttl" name="blocked_response_ttl"
type="number" control={control}
component={renderInputField} rules={{ validate: validateRequiredValue }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('form_enter_blocked_response_ttl')} <Input
normalize={toNumber} {...field}
validate={validateRequiredValue} data-testid="dns_config_blocked_response_ttl"
min={UINT32_RANGE.MIN} type="number"
max={UINT32_RANGE.MAX} label={t('blocked_response_ttl')}
desc={t('blocked_response_ttl_desc')}
error={fieldState.error?.message}
min={UINT32_RANGE.MIN}
max={UINT32_RANGE.MAX}
disabled={processing}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
</div> </div>
@@ -297,14 +381,13 @@ const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps
<button <button
type="submit" type="submit"
data-testid="dns_config_save"
className="btn btn-success btn-standard btn-large" className="btn btn-success btn-standard btn-large"
disabled={submitting || invalid || processing}> disabled={isSubmitting || !isDirty || processing}>
<Trans>save_btn</Trans> {t('save_btn')}
</button> </button>
</form> </form>
); );
}; };
export default reduxForm<Record<string, any>, Omit<ConfigFormProps, 'invalid' | 'submitting' | 'handleSubmit'>>({ export default Form;
form: FORM_NAME.BLOCKING_MODE,
})(Form);

View File

@@ -1,185 +1,110 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Field, reduxForm } from 'redux-form'; import i18next from 'i18next';
import { Trans, useTranslation } from 'react-i18next'; import clsx from 'clsx';
import classnames from 'classnames';
import Examples from './Examples';
import {
renderRadioField,
renderTextareaField,
CheckboxField,
renderInputField,
toNumber,
} from '../../../../helpers/form';
import {
DNS_REQUEST_OPTIONS,
FORM_NAME,
UINT32_RANGE,
UPSTREAM_CONFIGURATION_WIKI_LINK,
} from '../../../../helpers/constants';
import { testUpstreamWithFormValues } from '../../../../actions'; import { testUpstreamWithFormValues } from '../../../../actions';
import { DNS_REQUEST_OPTIONS, UINT32_RANGE, UPSTREAM_CONFIGURATION_WIKI_LINK } from '../../../../helpers/constants';
import { removeEmptyLines, trimLinesAndRemoveEmpty } from '../../../../helpers/helpers'; import { removeEmptyLines } from '../../../../helpers/helpers';
import { getTextareaCommentsHighlight, syncScroll } from '../../../../helpers/highlightTextareaComments'; import { getTextareaCommentsHighlight, syncScroll } from '../../../../helpers/highlightTextareaComments';
import '../../../ui/texareaCommentsHighlight.css';
import { RootState } from '../../../../initialState'; import { RootState } from '../../../../initialState';
import '../../../ui/texareaCommentsHighlight.css';
import Examples from './Examples';
import { Checkbox } from '../../../ui/Controls/Checkbox';
import { Textarea } from '../../../ui/Controls/Textarea';
import { Radio } from '../../../ui/Controls/Radio';
import { Input } from '../../../ui/Controls/Input';
import { validateRequiredValue } from '../../../../helpers/validators'; import { validateRequiredValue } from '../../../../helpers/validators';
import { toNumber } from '../../../../helpers/form';
const UPSTREAM_DNS_NAME = 'upstream_dns'; const UPSTREAM_DNS_NAME = 'upstream_dns';
const UPSTREAM_MODE_NAME = 'upstream_mode';
interface renderFieldProps { type FormData = {
name: string; upstream_dns: string;
component: any; upstream_mode: string;
type: string; fallback_dns: string;
className?: string; bootstrap_dns: string;
placeholder: string; local_ptr_upstreams: string;
subtitle?: string; use_private_ptr_resolvers: boolean;
value?: string; resolve_clients: boolean;
normalizeOnBlur?: (...args: unknown[]) => unknown; upstream_timeout: number;
containerClass?: string;
onScroll?: (...args: unknown[]) => unknown;
}
const renderField = ({
name,
component,
type,
className,
placeholder,
subtitle,
value,
normalizeOnBlur,
containerClass,
onScroll,
}: renderFieldProps) => {
const { t } = useTranslation();
const processingTestUpstream = useSelector((state: RootState) => state.settings.processingTestUpstream);
const processingSetConfig = useSelector((state: RootState) => state.dnsConfig.processingSetConfig);
return (
<div key={placeholder} className={classnames('col-12 mb-4', containerClass)}>
<Field
id={name}
value={value}
name={name}
component={component}
type={type}
className={className}
placeholder={t(placeholder)}
subtitle={t(subtitle)}
disabled={processingSetConfig || processingTestUpstream}
normalizeOnBlur={normalizeOnBlur}
onScroll={onScroll}
/>
</div>
);
}; };
interface renderTextareaWithHighlightFieldProps { type FormProps = {
className: string; initialValues?: Partial<FormData>;
disabled?: boolean; onSubmit: (data: FormData) => void;
id: string;
input?: object;
meta?: object;
normalizeOnBlur?: (...args: unknown[]) => unknown;
onScroll?: (...args: unknown[]) => unknown;
placeholder: string;
type: string;
}
const renderTextareaWithHighlightField = (props: renderTextareaWithHighlightFieldProps) => {
const upstream_dns = useSelector((store: RootState) => store.form[FORM_NAME.UPSTREAM].values.upstream_dns);
const upstream_dns_file = useSelector((state: RootState) => state.dnsConfig.upstream_dns_file);
const ref = useRef(null);
const onScroll = (e: any) => syncScroll(e, ref);
return (
<>
{renderTextareaField({
...props,
disabled: !!upstream_dns_file,
onScroll,
normalizeOnBlur: trimLinesAndRemoveEmpty,
})}
{getTextareaCommentsHighlight(ref, upstream_dns)}
</>
);
}; };
const INPUT_FIELDS = [ const upstreamModeOptions = [
{ {
name: UPSTREAM_MODE_NAME, label: i18next.t('load_balancing'),
type: 'radio', desc: <Trans components={{ br: <br />, b: <b /> }}>load_balancing_desc</Trans>,
value: DNS_REQUEST_OPTIONS.LOAD_BALANCING, value: DNS_REQUEST_OPTIONS.LOAD_BALANCING,
component: renderRadioField,
subtitle: 'load_balancing_desc',
placeholder: 'load_balancing',
}, },
{ {
name: UPSTREAM_MODE_NAME, label: i18next.t('parallel_requests'),
type: 'radio', desc: <Trans components={{ br: <br />, b: <b /> }}>upstream_parallel</Trans>,
value: DNS_REQUEST_OPTIONS.PARALLEL, value: DNS_REQUEST_OPTIONS.PARALLEL,
component: renderRadioField,
subtitle: 'upstream_parallel',
placeholder: 'parallel_requests',
}, },
{ {
name: UPSTREAM_MODE_NAME, label: i18next.t('fastest_addr'),
type: 'radio', desc: <Trans components={{ br: <br />, b: <b /> }}>fastest_addr_desc</Trans>,
value: DNS_REQUEST_OPTIONS.FASTEST_ADDR, value: DNS_REQUEST_OPTIONS.FASTEST_ADDR,
component: renderRadioField,
subtitle: 'fastest_addr_desc',
placeholder: 'fastest_addr',
}, },
]; ];
interface FormProps { const Form = ({ initialValues, onSubmit }: FormProps) => {
handleSubmit?: (...args: unknown[]) => string;
submitting?: boolean;
invalid?: boolean;
initialValues?: object;
upstream_dns?: string;
fallback_dns?: string;
bootstrap_dns?: string;
}
const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
const dispatch = useDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const upstream_dns = useSelector((store: RootState) => store.form[FORM_NAME.UPSTREAM].values.upstream_dns); const {
control,
const processingTestUpstream = useSelector((state: RootState) => state.settings.processingTestUpstream); handleSubmit,
watch,
const processingSetConfig = useSelector((state: RootState) => state.dnsConfig.processingSetConfig); formState: { isSubmitting, isDirty },
const defaultLocalPtrUpstreams = useSelector((state: RootState) => state.dnsConfig.default_local_ptr_upstreams); } = useForm<FormData>({
mode: 'onBlur',
const handleUpstreamTest = () => dispatch(testUpstreamWithFormValues()); defaultValues: {
upstream_dns: initialValues?.upstream_dns || '',
const testButtonClass = classnames('btn btn-primary btn-standard mr-2', { upstream_mode: initialValues?.upstream_mode || DNS_REQUEST_OPTIONS.LOAD_BALANCING,
'btn-loading': processingTestUpstream, fallback_dns: initialValues?.fallback_dns || '',
bootstrap_dns: initialValues?.bootstrap_dns || '',
local_ptr_upstreams: initialValues?.local_ptr_upstreams || '',
use_private_ptr_resolvers: initialValues?.use_private_ptr_resolvers || false,
resolve_clients: initialValues?.resolve_clients || false,
upstream_timeout: initialValues?.upstream_timeout || 0,
},
}); });
const components = { const upstream_dns = watch('upstream_dns');
a: <a href={UPSTREAM_CONFIGURATION_WIKI_LINK} target="_blank" rel="noopener noreferrer" />, const processingTestUpstream = useSelector((state: RootState) => state.settings.processingTestUpstream);
const processingSetConfig = useSelector((state: RootState) => state.dnsConfig.processingSetConfig);
const defaultLocalPtrUpstreams = useSelector((state: RootState) => state.dnsConfig.default_local_ptr_upstreams);
const upstream_dns_file = useSelector((state: RootState) => state.dnsConfig.upstream_dns_file);
const handleUpstreamTest = () => {
const formValues = {
bootstrap_dns: watch('bootstrap_dns'),
upstream_dns: watch('upstream_dns'),
local_ptr_upstreams: watch('local_ptr_upstreams'),
fallback_dns: watch('fallback_dns'),
};
dispatch(testUpstreamWithFormValues(formValues));
}; };
return ( return (
<form onSubmit={handleSubmit} className="form--upstream"> <form onSubmit={handleSubmit(onSubmit)} className="form--upstream">
<div className="row"> <div className="row">
<label className="col form__label" htmlFor={UPSTREAM_DNS_NAME}> <label className="col form__label" htmlFor="upstream_dns">
<Trans components={components}>upstream_dns_help</Trans>{' '} <Trans
components={{
a: <a href={UPSTREAM_CONFIGURATION_WIKI_LINK} target="_blank" rel="noopener noreferrer" />,
}}>
upstream_dns_help
</Trans>{' '}
<Trans <Trans
components={[ components={[
<a <a
@@ -196,44 +121,69 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
<div className="col-12 mb-4"> <div className="col-12 mb-4">
<div className="text-edit-container"> <div className="text-edit-container">
<Field <Controller
id={UPSTREAM_DNS_NAME} name="upstream_dns"
name={UPSTREAM_DNS_NAME} control={control}
component={renderTextareaWithHighlightField} render={({ field }) => (
type="text" <>
className="form-control form-control--textarea font-monospace text-input" <Textarea
placeholder={t('upstream_dns')} {...field}
disabled={processingSetConfig || processingTestUpstream} id={UPSTREAM_DNS_NAME}
normalizeOnBlur={removeEmptyLines} data-testid="upstream_dns"
className="form-control--textarea-large text-input"
wrapperClassName="mb-0"
placeholder={t('upstream_dns')}
disabled={!!upstream_dns_file || processingSetConfig || processingTestUpstream}
onScroll={(e) => syncScroll(e, textareaRef)}
trimOnBlur
/>
{getTextareaCommentsHighlight(textareaRef, upstream_dns)}
</>
)}
/> />
</div> </div>
</div> </div>
{INPUT_FIELDS.map(renderField)}
<div className="col-12"> <div className="col-12">
<Examples /> <Examples />
<hr /> <hr />
</div> </div>
<div className="col-12 mb-4">
<Controller
name="upstream_mode"
control={control}
render={({ field }) => (
<Radio
{...field}
options={upstreamModeOptions}
disabled={processingSetConfig || processingTestUpstream}
/>
)}
/>
</div>
<div className="col-12"> <div className="col-12">
<label className="form__label form__label--with-desc" htmlFor="fallback_dns"> <label className="form__label form__label--with-desc" htmlFor="fallback_dns">
<Trans>fallback_dns_title</Trans> {t('fallback_dns_title')}
</label> </label>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">{t('fallback_dns_desc')}</div>
<Trans>fallback_dns_desc</Trans>
</div>
<Field <Controller
id="fallback_dns"
name="fallback_dns" name="fallback_dns"
component={renderTextareaField} control={control}
type="text" render={({ field }) => (
className="form-control form-control--textarea form-control--textarea-small font-monospace" <Textarea
placeholder={t('fallback_dns_placeholder')} {...field}
disabled={processingSetConfig} id="fallback_dns"
normalizeOnBlur={removeEmptyLines} data-testid="fallback_dns"
wrapperClassName="mb-0"
placeholder={t('fallback_dns_placeholder')}
disabled={processingSetConfig}
trimOnBlur
/>
)}
/> />
</div> </div>
@@ -241,24 +191,30 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
<hr /> <hr />
</div> </div>
<div className="col-12 mb-2"> <div className="col-12">
<label className="form__label form__label--with-desc" htmlFor="bootstrap_dns"> <label className="form__label form__label--with-desc" htmlFor="bootstrap_dns">
<Trans>bootstrap_dns</Trans> {t('bootstrap_dns')}
</label> </label>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">{t('bootstrap_dns_desc')}</div>
<Trans>bootstrap_dns_desc</Trans>
</div>
<Field <Controller
id="bootstrap_dns"
name="bootstrap_dns" name="bootstrap_dns"
component={renderTextareaField} control={control}
type="text" render={({ field }) => (
className="form-control form-control--textarea form-control--textarea-small font-monospace" <Textarea
placeholder={t('bootstrap_dns')} {...field}
disabled={processingSetConfig} id="bootstrap_dns"
normalizeOnBlur={removeEmptyLines} data-testid="bootstrap_dns"
placeholder={t('bootstrap_dns')}
wrapperClassName="mb-0"
disabled={processingSetConfig}
onBlur={(e) => {
const value = removeEmptyLines(e.target.value);
field.onChange(value);
}}
/>
)}
/> />
</div> </div>
@@ -268,43 +224,47 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
<div className="col-12"> <div className="col-12">
<label className="form__label form__label--with-desc" htmlFor="local_ptr"> <label className="form__label form__label--with-desc" htmlFor="local_ptr">
<Trans>local_ptr_title</Trans> {t('local_ptr_title')}
</label> </label>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">{t('local_ptr_desc')}</div>
<Trans>local_ptr_desc</Trans>
</div>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">
{/** TODO: Add internazionalization for "" */} {defaultLocalPtrUpstreams?.length > 0
{defaultLocalPtrUpstreams?.length > 0 ? ( ? t('local_ptr_default_resolver', {
<Trans values={{ ip: defaultLocalPtrUpstreams.map((s: any) => `"${s}"`).join(', ') }}> ip: defaultLocalPtrUpstreams.map((s: any) => `"${s}"`).join(', '),
local_ptr_default_resolver })
</Trans> : t('local_ptr_no_default_resolver')}
) : (
<Trans>local_ptr_no_default_resolver</Trans>
)}
</div> </div>
<Field <Controller
id="local_ptr_upstreams"
name="local_ptr_upstreams" name="local_ptr_upstreams"
component={renderTextareaField} control={control}
type="text" render={({ field }) => (
className="form-control form-control--textarea form-control--textarea-small font-monospace" <Textarea
placeholder={t('local_ptr_placeholder')} {...field}
disabled={processingSetConfig} id="local_ptr_upstreams"
normalizeOnBlur={removeEmptyLines} data-testid="local_ptr_upstreams"
placeholder={t('local_ptr_placeholder')}
disabled={processingSetConfig}
trimOnBlur
/>
)}
/> />
<div className="mt-4"> <div className="mt-4">
<Field <Controller
name="use_private_ptr_resolvers" name="use_private_ptr_resolvers"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('use_private_ptr_resolvers_title')} <Checkbox
subtitle={t('use_private_ptr_resolvers_desc')} {...field}
disabled={processingSetConfig} data-testid="dns_use_private_ptr_resolvers"
title={t('use_private_ptr_resolvers_title')}
subtitle={t('use_private_ptr_resolvers_desc')}
disabled={processingSetConfig}
/>
)}
/> />
</div> </div>
</div> </div>
@@ -313,14 +273,19 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
<hr /> <hr />
</div> </div>
<div className="col-12"> <div className="col-12 mb-4">
<Field <Controller
name="resolve_clients" name="resolve_clients"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('resolve_clients_title')} <Checkbox
subtitle={t('resolve_clients_desc')} {...field}
disabled={processingSetConfig} data-testid="dns_resolve_clients"
title={t('resolve_clients_title')}
subtitle={t('resolve_clients_desc')}
disabled={processingSetConfig}
/>
)}
/> />
</div> </div>
@@ -338,16 +303,26 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
<Trans>upstream_timeout_desc</Trans> <Trans>upstream_timeout_desc</Trans>
</div> </div>
<Field <Controller
name="upstream_timeout" name="upstream_timeout"
type="number" control={control}
component={renderInputField} rules={{ validate: validateRequiredValue }}
className="form-control" render={({ field }) => (
placeholder={t('form_enter_upstream_timeout')} <Input
normalize={toNumber} {...field}
validate={validateRequiredValue} type="number"
min={1} id="upstream_timeout"
max={UINT32_RANGE.MAX} data-testid="upstream_timeout"
placeholder={t('form_enter_upstream_timeout')}
disabled={processingSetConfig}
min={1}
max={UINT32_RANGE.MAX}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
</div> </div>
@@ -357,17 +332,21 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
<div className="btn-list"> <div className="btn-list">
<button <button
type="button" type="button"
className={testButtonClass} data-testid="dns_upstream_test"
className={clsx('btn btn-primary btn-standard mr-2', {
'btn-loading': processingTestUpstream,
})}
onClick={handleUpstreamTest} onClick={handleUpstreamTest}
disabled={!upstream_dns || processingTestUpstream}> disabled={!upstream_dns || processingTestUpstream}>
<Trans>test_upstream_btn</Trans> {t('test_upstream_btn')}
</button> </button>
<button <button
type="submit" type="submit"
data-testid="dns_upstream_save"
className="btn btn-success btn-standard" className="btn btn-success btn-standard"
disabled={submitting || invalid || processingSetConfig || processingTestUpstream}> disabled={isSubmitting || !isDirty || processingSetConfig || processingTestUpstream}>
<Trans>apply_btn</Trans> {t('apply_btn')}
</button> </button>
</div> </div>
</div> </div>
@@ -375,4 +354,4 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
); );
}; };
export default reduxForm({ form: FORM_NAME.UPSTREAM })(Form); export default Form;

View File

@@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Field, reduxForm, formValueSelector } from 'redux-form'; import { Trans, useTranslation } from 'react-i18next';
import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import { renderInputField, CheckboxField, renderRadioField, toNumber } from '../../../helpers/form'; import { Controller, useForm } from 'react-hook-form';
import i18next from 'i18next';
import { import {
validateServerName, validateServerName,
validateIsSafePort, validateIsSafePort,
@@ -14,7 +12,6 @@ import {
validatePortTLS, validatePortTLS,
validatePlainDns, validatePlainDns,
} from '../../../helpers/validators'; } from '../../../helpers/validators';
import i18n from '../../../i18n';
import KeyStatus from './KeyStatus'; import KeyStatus from './KeyStatus';
@@ -22,51 +19,39 @@ import CertificateStatus from './CertificateStatus';
import { import {
DNS_OVER_QUIC_PORT, DNS_OVER_QUIC_PORT,
DNS_OVER_TLS_PORT, DNS_OVER_TLS_PORT,
FORM_NAME,
STANDARD_HTTPS_PORT, STANDARD_HTTPS_PORT,
ENCRYPTION_SOURCE, ENCRYPTION_SOURCE,
} from '../../../helpers/constants'; } from '../../../helpers/constants';
import { Checkbox } from '../../ui/Controls/Checkbox';
import { Radio } from '../../ui/Controls/Radio';
import { Input } from '../../ui/Controls/Input';
import { Textarea } from '../../ui/Controls/Textarea';
import { EncryptionData } from '../../../initialState';
import { toNumber } from '../../../helpers/form';
const validate = (values: any) => { const certificateSourceOptions = [
const errors: { port_dns_over_tls?: string; port_https?: string } = {}; {
label: i18next.t('encryption_certificates_source_path'),
value: ENCRYPTION_SOURCE.PATH,
},
{
label: i18next.t('encryption_certificates_source_content'),
value: ENCRYPTION_SOURCE.CONTENT,
},
];
if (values.port_dns_over_tls && values.port_https) { const keySourceOptions = [
if (values.port_dns_over_tls === values.port_https) { {
errors.port_dns_over_tls = i18n.t('form_error_equal'); label: i18next.t('encryption_key_source_path'),
value: ENCRYPTION_SOURCE.PATH,
},
{
label: i18next.t('encryption_key_source_content'),
value: ENCRYPTION_SOURCE.CONTENT,
},
];
errors.port_https = i18n.t('form_error_equal'); const validationMessage = (warningValidation: string, isWarning: boolean) => {
}
}
return errors;
};
const clearFields = (change: any, setTlsConfig: any, validateTlsConfig: any, t: any) => {
const fields = {
private_key: '',
certificate_chain: '',
private_key_path: '',
certificate_path: '',
port_https: STANDARD_HTTPS_PORT,
port_dns_over_tls: DNS_OVER_TLS_PORT,
port_dns_over_quic: DNS_OVER_QUIC_PORT,
server_name: '',
force_https: false,
enabled: false,
private_key_saved: false,
serve_plain_dns: true,
};
// eslint-disable-next-line no-alert
if (window.confirm(t('encryption_reset'))) {
Object.keys(fields)
.forEach((field) => change(field, fields[field]));
setTlsConfig(fields);
validateTlsConfig(fields);
}
};
const validationMessage = (warningValidation: any, isWarning: any) => {
if (!warningValidation) { if (!warningValidation) {
return null; return null;
} }
@@ -88,56 +73,60 @@ const validationMessage = (warningValidation: any, isWarning: any) => {
); );
}; };
interface FormProps { export type EncryptionFormValues = {
handleSubmit: (...args: unknown[]) => string; enabled?: boolean;
handleChange?: (...args: unknown[]) => unknown; serve_plain_dns?: boolean;
isEnabled: boolean; server_name?: string;
servePlainDns: boolean; force_https?: boolean;
certificateChain: string; port_https?: number;
privateKey: string; port_dns_over_tls?: number;
certificatePath: string; port_dns_over_quic?: number;
privateKeyPath: string; certificate_chain?: string;
change: (...args: unknown[]) => unknown; private_key?: string;
submitting: boolean; certificate_path?: string;
invalid: boolean; private_key_path?: string;
initialValues: object; certificate_source?: string;
processingConfig: boolean; key_source?: string;
processingValidate: boolean; private_key_saved?: boolean;
status_key?: string; };
not_after?: string;
warning_validation?: string; type Props = {
valid_chain?: boolean; initialValues: EncryptionFormValues;
valid_key?: boolean; encryption: EncryptionData;
valid_cert?: boolean; onSubmit: (values: EncryptionFormValues) => void;
valid_pair?: boolean; debouncedConfigValidation: (values: EncryptionFormValues) => void;
dns_names?: string[]; setTlsConfig: (values: Partial<EncryptionData>) => void;
key_type?: string; validateTlsConfig: (values: Partial<EncryptionData>) => void;
issuer?: string; };
subject?: string;
t: (...args: unknown[]) => string; const defaultValues = {
setTlsConfig: (...args: unknown[]) => unknown; enabled: false,
validateTlsConfig: (...args: unknown[]) => unknown; serve_plain_dns: true,
certificateSource?: string; server_name: '',
privateKeySource?: string; force_https: false,
privateKeySaved?: boolean; port_https: STANDARD_HTTPS_PORT,
} port_dns_over_tls: DNS_OVER_TLS_PORT,
port_dns_over_quic: DNS_OVER_QUIC_PORT,
certificate_chain: '',
private_key: '',
certificate_path: '',
private_key_path: '',
certificate_source: ENCRYPTION_SOURCE.PATH,
key_source: ENCRYPTION_SOURCE.PATH,
private_key_saved: false,
};
export const Form = ({
initialValues,
encryption,
onSubmit,
setTlsConfig,
debouncedConfigValidation,
validateTlsConfig,
}: Props) => {
const { t } = useTranslation();
let Form = (props: FormProps) => {
const { const {
t,
handleSubmit,
handleChange,
isEnabled,
servePlainDns,
certificateChain,
privateKey,
certificatePath,
privateKeyPath,
change,
invalid,
submitting,
processingConfig,
processingValidate,
not_after, not_after,
valid_chain, valid_chain,
valid_key, valid_key,
@@ -148,37 +137,100 @@ let Form = (props: FormProps) => {
issuer, issuer,
subject, subject,
warning_validation, warning_validation,
setTlsConfig, processingConfig,
validateTlsConfig, processingValidate,
certificateSource, } = encryption;
privateKeySource,
privateKeySaved, const {
} = props; control,
handleSubmit,
watch,
reset,
setValue,
setError,
getValues,
formState: { isSubmitting, isValid },
} = useForm<EncryptionFormValues>({
defaultValues: {
...defaultValues,
...initialValues,
},
mode: 'onBlur',
});
const {
enabled: isEnabled,
serve_plain_dns: servePlainDns,
certificate_chain: certificateChain,
private_key: privateKey,
private_key_path: privateKeyPath,
key_source: privateKeySource,
private_key_saved: privateKeySaved,
certificate_path: certificatePath,
certificate_source: certificateSource,
} = watch();
const handleBlur = () => {
debouncedConfigValidation(getValues());
};
const isSavingDisabled = () => { const isSavingDisabled = () => {
const processing = submitting || processingConfig || processingValidate; const processing = isSubmitting || processingConfig || processingValidate;
if (servePlainDns && !isEnabled) { if (servePlainDns && !isEnabled) {
return invalid || processing; return !isValid || processing;
} }
return invalid || processing || !valid_key || !valid_cert || !valid_pair; return !isValid || processing || !valid_key || !valid_cert || !valid_pair;
};
const clearFields = () => {
if (window.confirm(t('encryption_reset'))) {
reset();
setTlsConfig(defaultValues);
validateTlsConfig(defaultValues);
}
};
const validatePorts = (values: EncryptionFormValues) => {
const errors: { port_dns_over_tls?: string; port_https?: string } = {};
if (values.port_dns_over_tls && values.port_https) {
if (values.port_dns_over_tls === values.port_https) {
errors.port_dns_over_tls = i18next.t('form_error_equal');
errors.port_https = i18next.t('form_error_equal');
}
}
return errors;
};
const onFormSubmit = (data: EncryptionFormValues) => {
const validationErrors = validatePorts(data);
if (Object.keys(validationErrors).length > 0) {
Object.entries(validationErrors).forEach(([field, message]) => {
setError(field as keyof EncryptionFormValues, { type: 'manual', message });
});
} else {
onSubmit(data);
}
}; };
const isDisabled = isSavingDisabled(); const isDisabled = isSavingDisabled();
const isWarning = valid_key && valid_cert && valid_pair; const isWarning = valid_key && valid_cert && valid_pair;
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onFormSubmit)}>
<div className="row"> <div className="row">
<div className="col-12"> <div className="col-12">
<div className="form__group form__group--settings mb-3"> <div className="form__group form__group--settings mb-3">
<Field <Controller
name="enabled" name="enabled"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('encryption_enable')} <Checkbox {...field} title={t('encryption_enable')} onBlur={handleBlur} />
onChange={handleChange} )}
/> />
</div> </div>
@@ -187,13 +239,13 @@ let Form = (props: FormProps) => {
</div> </div>
<div className="form__group mb-3 mt-5"> <div className="form__group mb-3 mt-5">
<Field <Controller
name="serve_plain_dns" name="serve_plain_dns"
type="checkbox" control={control}
component={CheckboxField} rules={{
placeholder={t('encryption_plain_dns_enable')} validate: (value) => validatePlainDns(value, getValues()),
onChange={handleChange} }}
validate={validatePlainDns} render={({ field }) => <Checkbox {...field} title={t('encryption_plain_dns_enable')} />}
/> />
</div> </div>
@@ -212,16 +264,20 @@ let Form = (props: FormProps) => {
<div className="col-lg-6"> <div className="col-lg-6">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
id="server_name"
name="server_name" name="server_name"
component={renderInputField} control={control}
type="text" rules={{ validate: validateServerName }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('encryption_server_enter')} <Input
onChange={handleChange} {...field}
disabled={!isEnabled} type="text"
validate={validateServerName} placeholder={t('encryption_server_enter')}
error={fieldState.error?.message}
disabled={!isEnabled}
onBlur={handleBlur}
/>
)}
/> />
<div className="form__desc"> <div className="form__desc">
@@ -232,13 +288,12 @@ let Form = (props: FormProps) => {
<div className="col-lg-6"> <div className="col-lg-6">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name="force_https" name="force_https"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('encryption_redirect')} <Checkbox {...field} title={t('encryption_redirect')} disabled={!isEnabled} />
onChange={handleChange} )}
disabled={!isEnabled}
/> />
<div className="form__desc"> <div className="form__desc">
@@ -255,17 +310,24 @@ let Form = (props: FormProps) => {
<Trans>encryption_https</Trans> <Trans>encryption_https</Trans>
</label> </label>
<Field <Controller
id="port_https"
name="port_https" name="port_https"
component={renderInputField} control={control}
type="number" rules={{ validate: { validatePort, validateIsSafePort } }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('encryption_https')} <Input
validate={[validatePort, validateIsSafePort]} {...field}
normalize={toNumber} type="number"
onChange={handleChange} placeholder={t('encryption_https')}
disabled={!isEnabled} error={fieldState.error?.message}
disabled={!isEnabled}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
onBlur={handleBlur}
/>
)}
/> />
<div className="form__desc"> <div className="form__desc">
@@ -280,17 +342,24 @@ let Form = (props: FormProps) => {
<Trans>encryption_dot</Trans> <Trans>encryption_dot</Trans>
</label> </label>
<Field <Controller
id="port_dns_over_tls"
name="port_dns_over_tls" name="port_dns_over_tls"
component={renderInputField} control={control}
type="number" rules={{ validate: validatePortTLS }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('encryption_dot')} <Input
validate={[validatePortTLS]} {...field}
normalize={toNumber} type="number"
onChange={handleChange} placeholder={t('encryption_dot')}
disabled={!isEnabled} error={fieldState.error?.message}
disabled={!isEnabled}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
onBlur={handleBlur}
/>
)}
/> />
<div className="form__desc"> <div className="form__desc">
@@ -305,17 +374,24 @@ let Form = (props: FormProps) => {
<Trans>encryption_doq</Trans> <Trans>encryption_doq</Trans>
</label> </label>
<Field <Controller
id="port_dns_over_quic"
name="port_dns_over_quic" name="port_dns_over_quic"
component={renderInputField} control={control}
type="number" rules={{ validate: validatePortQuic }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('encryption_doq')} <Input
validate={[validatePortQuic]} {...field}
normalize={toNumber} type="number"
onChange={handleChange} placeholder={t('encryption_doq')}
disabled={!isEnabled} error={fieldState.error?.message}
disabled={!isEnabled}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
onBlur={handleBlur}
/>
)}
/> />
<div className="form__desc"> <div className="form__desc">
@@ -352,50 +428,44 @@ let Form = (props: FormProps) => {
<div className="form__inline mb-2"> <div className="form__inline mb-2">
<div className="custom-controls-stacked"> <div className="custom-controls-stacked">
<Field <Controller
name="certificate_source" name="certificate_source"
component={renderRadioField} control={control}
type="radio" render={({ field }) => (
className="form-control mr-2" <Radio {...field} options={certificateSourceOptions} disabled={!isEnabled} />
value="path" )}
placeholder={t('encryption_certificates_source_path')}
disabled={!isEnabled}
/>
<Field
name="certificate_source"
component={renderRadioField}
type="radio"
className="form-control mr-2"
value="content"
placeholder={t('encryption_certificates_source_content')}
disabled={!isEnabled}
/> />
</div> </div>
</div> </div>
{certificateSource === ENCRYPTION_SOURCE.CONTENT && ( {certificateSource === ENCRYPTION_SOURCE.CONTENT ? (
<Field <Controller
id="certificate_chain"
name="certificate_chain" name="certificate_chain"
component="textarea" control={control}
type="text" render={({ field, fieldState }) => (
className="form-control form-control--textarea" <Textarea
placeholder={t('encryption_certificates_input')} {...field}
onChange={handleChange} placeholder={t('encryption_certificates_input')}
disabled={!isEnabled} disabled={!isEnabled}
error={fieldState.error?.message}
onBlur={handleBlur}
/>
)}
/> />
)} ) : (
{certificateSource === ENCRYPTION_SOURCE.PATH && ( <Controller
<Field
id="certificate_path"
name="certificate_path" name="certificate_path"
component={renderInputField} control={control}
type="text" render={({ field, fieldState }) => (
className="form-control" <Input
placeholder={t('encryption_certificate_path')} {...field}
onChange={handleChange} type="text"
disabled={!isEnabled} placeholder={t('encryption_certificate_path')}
error={fieldState.error?.message}
disabled={!isEnabled}
onBlur={handleBlur}
/>
)}
/> />
)} )}
</div> </div>
@@ -424,70 +494,67 @@ let Form = (props: FormProps) => {
<div className="form__inline mb-2"> <div className="form__inline mb-2">
<div className="custom-controls-stacked"> <div className="custom-controls-stacked">
<Field <Controller
name="key_source" name="key_source"
component={renderRadioField} control={control}
type="radio" render={({ field }) => (
className="form-control mr-2" <Radio {...field} options={keySourceOptions} disabled={!isEnabled} />
value={ENCRYPTION_SOURCE.PATH} )}
placeholder={t('encryption_key_source_path')}
disabled={!isEnabled}
/>
<Field
name="key_source"
component={renderRadioField}
type="radio"
className="form-control mr-2"
value={ENCRYPTION_SOURCE.CONTENT}
placeholder={t('encryption_key_source_content')}
disabled={!isEnabled}
/> />
</div> </div>
</div> </div>
{privateKeySource === ENCRYPTION_SOURCE.PATH && ( {privateKeySource === ENCRYPTION_SOURCE.CONTENT ? (
<Field <>
<Controller
name="private_key_saved"
control={control}
render={({ field }) => (
<Checkbox
{...field}
title={t('use_saved_key')}
disabled={!isEnabled}
onChange={(checked: boolean) => {
if (checked) {
setValue('private_key', '');
}
field.onChange(checked);
}}
onBlur={handleBlur}
/>
)}
/>
<Controller
name="private_key"
control={control}
render={({ field, fieldState }) => (
<Textarea
{...field}
placeholder={t('encryption_key_input')}
disabled={!isEnabled || privateKeySaved}
error={fieldState.error?.message}
onBlur={handleBlur}
/>
)}
/>
</>
) : (
<Controller
name="private_key_path" name="private_key_path"
component={renderInputField} control={control}
type="text" render={({ field, fieldState }) => (
className="form-control" <Input
placeholder={t('encryption_private_key_path')} {...field}
onChange={handleChange} type="text"
disabled={!isEnabled} placeholder={t('encryption_private_key_path')}
error={fieldState.error?.message}
disabled={!isEnabled}
onBlur={handleBlur}
/>
)}
/> />
)} )}
{privateKeySource === ENCRYPTION_SOURCE.CONTENT && [
<Field
key="private_key_saved"
name="private_key_saved"
type="checkbox"
className="form__group form__group--settings mb-2"
component={CheckboxField}
disabled={!isEnabled}
placeholder={t('use_saved_key')}
onChange={(event: any) => {
if (event.target.checked) {
change('private_key', '');
}
if (handleChange) {
handleChange(event);
}
}}
/>,
<Field
id="private_key"
key="private_key"
name="private_key"
component="textarea"
type="text"
className="form-control form-control--textarea"
placeholder={t('encryption_key_input')}
onChange={handleChange}
disabled={!isEnabled || privateKeySaved}
/>,
]}
</div> </div>
<div className="form__status"> <div className="form__status">
@@ -505,44 +572,11 @@ let Form = (props: FormProps) => {
<button <button
type="button" type="button"
className="btn btn-secondary btn-standart" className="btn btn-secondary btn-standart"
disabled={submitting || processingConfig} disabled={isSubmitting || processingConfig}
onClick={() => clearFields(change, setTlsConfig, validateTlsConfig, t)}> onClick={clearFields}>
<Trans>reset_settings</Trans> <Trans>reset_settings</Trans>
</button> </button>
</div> </div>
</form> </form>
); );
}; };
const selector = formValueSelector(FORM_NAME.ENCRYPTION);
Form = connect((state) => {
const isEnabled = selector(state, 'enabled');
const servePlainDns = selector(state, 'serve_plain_dns');
const certificateChain = selector(state, 'certificate_chain');
const privateKey = selector(state, 'private_key');
const certificatePath = selector(state, 'certificate_path');
const privateKeyPath = selector(state, 'private_key_path');
const certificateSource = selector(state, 'certificate_source');
const privateKeySource = selector(state, 'key_source');
const privateKeySaved = selector(state, 'private_key_saved');
return {
isEnabled,
servePlainDns,
certificateChain,
privateKey,
certificatePath,
privateKeyPath,
certificateSource,
privateKeySource,
privateKeySaved,
};
})(Form);
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.ENCRYPTION,
validate,
}),
])(Form);

View File

@@ -1,61 +1,60 @@
import React, { Component } from 'react'; import React, { useCallback, useMemo } from 'react';
import { withTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import debounce from 'lodash/debounce'; import { debounce } from 'lodash';
import { DEBOUNCE_TIMEOUT, ENCRYPTION_SOURCE } from '../../../helpers/constants'; import { DEBOUNCE_TIMEOUT, ENCRYPTION_SOURCE } from '../../../helpers/constants';
import Form from './Form'; import { EncryptionFormValues, Form } from './Form';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import PageTitle from '../../ui/PageTitle'; import PageTitle from '../../ui/PageTitle';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import { EncryptionData } from '../../../initialState'; import { EncryptionData } from '../../../initialState';
interface EncryptionProps { type Props = {
setTlsConfig: (...args: unknown[]) => unknown;
validateTlsConfig: (...args: unknown[]) => unknown;
encryption: EncryptionData; encryption: EncryptionData;
t: (...args: unknown[]) => string; setTlsConfig: (values: Partial<EncryptionData>) => void;
} validateTlsConfig: (values: Partial<EncryptionData>) => void;
};
class Encryption extends Component<EncryptionProps> { export const Encryption = ({ encryption, setTlsConfig, validateTlsConfig }: Props) => {
componentDidMount() { const { t } = useTranslation();
const { validateTlsConfig, encryption } = this.props;
if (encryption.enabled) { const initialValues = useMemo((): EncryptionFormValues => {
validateTlsConfig(encryption); const {
} enabled,
} serve_plain_dns,
server_name,
handleFormSubmit = (values: any) => { force_https,
const submitValues = this.getSubmitValues(values); port_https,
port_dns_over_tls,
this.props.setTlsConfig(submitValues); port_dns_over_quic,
}; certificate_chain,
private_key,
handleFormChange = debounce((values) => { certificate_path,
const submitValues = this.getSubmitValues(values); private_key_path,
private_key_saved,
if (submitValues.enabled) { } = encryption;
this.props.validateTlsConfig(submitValues);
}
}, DEBOUNCE_TIMEOUT);
getInitialValues = (data: any) => {
const { certificate_chain, private_key, private_key_saved } = data;
const certificate_source = certificate_chain ? ENCRYPTION_SOURCE.CONTENT : ENCRYPTION_SOURCE.PATH; const certificate_source = certificate_chain ? ENCRYPTION_SOURCE.CONTENT : ENCRYPTION_SOURCE.PATH;
const key_source = private_key || private_key_saved ? ENCRYPTION_SOURCE.CONTENT : ENCRYPTION_SOURCE.PATH; const key_source = private_key || private_key_saved ? ENCRYPTION_SOURCE.CONTENT : ENCRYPTION_SOURCE.PATH;
return { return {
...data, enabled,
serve_plain_dns,
server_name,
force_https,
port_https,
port_dns_over_tls,
port_dns_over_quic,
certificate_chain,
private_key,
certificate_path,
private_key_path,
private_key_saved,
certificate_source, certificate_source,
key_source, key_source,
}; };
}; }, [encryption]);
getSubmitValues = (values: any) => { const getSubmitValues = useCallback((values: any) => {
const { certificate_source, key_source, private_key_saved, ...config } = values; const { certificate_source, key_source, private_key_saved, ...config } = values;
if (certificate_source === ENCRYPTION_SOURCE.PATH) { if (certificate_source === ENCRYPTION_SOURCE.PATH) {
@@ -76,63 +75,47 @@ class Encryption extends Component<EncryptionProps> {
} }
return config; return config;
}; }, []);
render() { const handleFormSubmit = useCallback(
const { encryption, t } = this.props; (values: any) => {
const { const submitValues = getSubmitValues(values);
enabled, setTlsConfig(submitValues);
server_name, },
force_https, [getSubmitValues, setTlsConfig],
port_https, );
port_dns_over_tls,
port_dns_over_quic,
certificate_chain,
private_key,
certificate_path,
private_key_path,
private_key_saved,
serve_plain_dns,
} = encryption;
const initialValues = this.getInitialValues({ const validateConfig = useCallback((values) => {
enabled, const submitValues = getSubmitValues(values);
server_name,
force_https,
port_https,
port_dns_over_tls,
port_dns_over_quic,
certificate_chain,
private_key,
certificate_path,
private_key_path,
private_key_saved,
serve_plain_dns,
});
return ( if (submitValues.enabled) {
<div className="encryption"> validateTlsConfig(submitValues);
<PageTitle title={t('encryption_settings')} /> }
}, []);
{encryption.processing && <Loading />} const debouncedConfigValidation = useMemo(() => debounce(validateConfig, DEBOUNCE_TIMEOUT), [validateConfig]);
{!encryption.processing && (
<Card
title={t('encryption_title')}
subtitle={t('encryption_desc')}
bodyType="card-body box-body--settings">
<Form
initialValues={initialValues}
onSubmit={this.handleFormSubmit}
onChange={this.handleFormChange}
setTlsConfig={this.props.setTlsConfig}
validateTlsConfig={this.props.validateTlsConfig}
{...this.props.encryption}
/>
</Card>
)}
</div>
);
}
}
export default withTranslation()(Encryption); return (
<div className="encryption">
<PageTitle title={t('encryption_settings')} />
{encryption.processing ? (
<Loading />
) : (
<Card
title={t('encryption_title')}
subtitle={t('encryption_desc')}
bodyType="card-body box-body--settings">
<Form
initialValues={initialValues}
onSubmit={handleFormSubmit}
debouncedConfigValidation={debouncedConfigValidation}
setTlsConfig={setTlsConfig}
validateTlsConfig={validateTlsConfig}
encryption={encryption}
/>
</Card>
)}
</div>
);
};

View File

@@ -1,85 +0,0 @@
import React from 'react';
import { Field, reduxForm } from 'redux-form';
import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import { CheckboxField, toNumber } from '../../../helpers/form';
import { FILTERS_INTERVALS_HOURS, FILTERS_RELATIVE_LINK, FORM_NAME } from '../../../helpers/constants';
const getTitleForInterval = (interval: any, t: any) => {
if (interval === 0) {
return t('disabled');
}
if (interval === 72 || interval === 168) {
return t('interval_days', { count: interval / 24 });
}
return t('interval_hours', { count: interval });
};
const getIntervalSelect = (processing: any, t: any, handleChange: any, toNumber: any) => (
<Field
name="interval"
className="custom-select"
component="select"
onChange={handleChange}
normalize={toNumber}
disabled={processing}>
{FILTERS_INTERVALS_HOURS.map((interval) => (
<option value={interval} key={interval}>
{getTitleForInterval(interval, t)}
</option>
))}
</Field>
);
interface FormProps {
handleSubmit: (...args: unknown[]) => string;
handleChange?: (...args: unknown[]) => unknown;
change: (...args: unknown[]) => unknown;
submitting: boolean;
invalid: boolean;
processing: boolean;
t: (...args: unknown[]) => string;
}
const Form = (props: FormProps) => {
const { handleSubmit, handleChange, processing, t } = props;
const components = {
a: <a href={FILTERS_RELATIVE_LINK} rel="noopener noreferrer" />,
};
return (
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col-12">
<div className="form__group form__group--settings">
<Field
name="enabled"
type="checkbox"
modifier="checkbox--settings"
component={CheckboxField}
placeholder={t('block_domain_use_filters_and_hosts')}
subtitle={<Trans components={components}>filters_block_toggle_hint</Trans>}
onChange={handleChange}
disabled={processing}
/>
</div>
</div>
<div className="col-12 col-md-5">
<div className="form__group form__group--inner mb-5">
<label className="form__label">
<Trans>filters_interval</Trans>
</label>
{getIntervalSelect(processing, t, handleChange, toNumber)}
</div>
</div>
</div>
</form>
);
};
export default flow([withTranslation(), reduxForm({ form: FORM_NAME.FILTER_CONFIG })])(Form);

View File

@@ -1,39 +1,115 @@
import React from 'react'; import React, { useEffect, useRef } from 'react';
import { withTranslation } from 'react-i18next'; import { Controller, useForm } from 'react-hook-form';
import debounce from 'lodash/debounce'; import { Trans, useTranslation } from 'react-i18next';
import { DEBOUNCE_TIMEOUT } from '../../../helpers/constants'; import i18next from 'i18next';
import { toNumber } from '../../../helpers/form';
import { DAY, FILTERS_INTERVALS_HOURS, FILTERS_RELATIVE_LINK } from '../../../helpers/constants';
import { Checkbox } from '../../ui/Controls/Checkbox';
import { Select } from '../../ui/Controls/Select';
import Form from './Form'; const THREE_DAYS_INTERVAL = DAY * 3;
const SEVEN_DAYS_INTERVAL = DAY * 7;
import { getObjDiff } from '../../../helpers/helpers'; const getTitleForInterval = (interval: number) => {
if (interval === 0) {
return i18next.t('disabled');
}
interface FiltersConfigProps { if (interval === THREE_DAYS_INTERVAL || interval === SEVEN_DAYS_INTERVAL) {
initialValues: object; return i18next.t('interval_days', { count: interval / DAY });
processing: boolean; }
setFiltersConfig: (...args: unknown[]) => unknown;
t: (...args: unknown[]) => string;
}
const FiltersConfig = (props: FiltersConfigProps) => { return i18next.t('interval_hours', { count: interval });
const { initialValues, processing } = props;
const handleFormChange = debounce((values) => {
const diff = getObjDiff(initialValues, values);
if (Object.values(diff).length > 0) {
props.setFiltersConfig(values);
}
}, DEBOUNCE_TIMEOUT);
return (
<Form
initialValues={initialValues}
onSubmit={handleFormChange}
onChange={handleFormChange}
processing={processing}
/>
);
}; };
export default withTranslation()(FiltersConfig); export type FormValues = {
enabled: boolean;
interval: number;
};
type Props = {
initialValues: FormValues;
setFiltersConfig: (values: FormValues) => void;
processing: boolean;
};
export const FiltersConfig = ({ initialValues, setFiltersConfig, processing }: Props) => {
const { t } = useTranslation();
const prevFormValuesRef = useRef<FormValues>(initialValues);
const { watch, control } = useForm({
mode: 'onBlur',
defaultValues: initialValues,
});
const formValues = watch();
useEffect(() => {
const prevFormValues = prevFormValuesRef.current;
if (JSON.stringify(prevFormValues) !== JSON.stringify(formValues)) {
setFiltersConfig(formValues);
prevFormValuesRef.current = formValues;
}
}, [formValues]);
const components = {
a: <a href={FILTERS_RELATIVE_LINK} rel="noopener noreferrer" />,
};
return (
<>
<div className="row">
<div className="col-12">
<div className="form__group form__group--settings">
<Controller
name="enabled"
control={control}
render={({ field }) => (
<Checkbox
{...field}
data-testid="filters_enabled"
title={t('block_domain_use_filters_and_hosts')}
disabled={processing}
/>
)}
/>
<p>
<Trans components={components}>filters_block_toggle_hint</Trans>
</p>
</div>
</div>
<div className="col-12 col-md-5">
<div className="form__group form__group--inner mb-5">
<label className="form__label">
<Trans>filters_interval</Trans>
</label>
<Controller
name="interval"
control={control}
render={({ field }) => (
<Select
{...field}
data-testid="filters_interval"
disabled={processing}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}>
{FILTERS_INTERVALS_HOURS.map((interval) => (
<option value={interval} key={interval}>
{getTitleForInterval(interval)}
</option>
))}
</Select>
)}
/>
</div>
</div>
</div>
</>
);
};

View File

@@ -1,147 +1,182 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { change, Field, formValueSelector, reduxForm } from 'redux-form'; import { Trans, useTranslation } from 'react-i18next';
import { connect } from 'react-redux'; import i18next from 'i18next';
import { Trans, withTranslation } from 'react-i18next'; import { Controller, useForm } from 'react-hook-form';
import flow from 'lodash/flow';
import {
CheckboxField,
toFloatNumber,
renderTextareaField,
renderInputField,
renderRadioField,
} from '../../../helpers/form';
import { trimLinesAndRemoveEmpty } from '../../../helpers/helpers'; import { trimLinesAndRemoveEmpty } from '../../../helpers/helpers';
import { import { QUERY_LOG_INTERVALS_DAYS, HOUR, DAY, RETENTION_CUSTOM, RETENTION_RANGE } from '../../../helpers/constants';
FORM_NAME,
QUERY_LOG_INTERVALS_DAYS,
HOUR,
DAY,
RETENTION_CUSTOM,
RETENTION_CUSTOM_INPUT,
RETENTION_RANGE,
CUSTOM_INTERVAL,
} from '../../../helpers/constants';
import '../FormButton.css'; import '../FormButton.css';
import { Checkbox } from '../../ui/Controls/Checkbox';
import { Input } from '../../ui/Controls/Input';
import { toNumber } from '../../../helpers/form';
import { Textarea } from '../../ui/Controls/Textarea';
const getIntervalTitle = (interval: any, t: any) => { const getIntervalTitle = (interval: number) => {
switch (interval) { switch (interval) {
case RETENTION_CUSTOM: case RETENTION_CUSTOM:
return t('settings_custom'); return i18next.t('settings_custom');
case 6 * HOUR: case 6 * HOUR:
return t('interval_6_hour'); return i18next.t('interval_6_hour');
case DAY: case DAY:
return t('interval_24_hour'); return i18next.t('interval_24_hour');
default: default:
return t('interval_days', { count: interval / DAY }); return i18next.t('interval_days', { count: interval / DAY });
} }
}; };
const getIntervalFields = (processing: any, t: any, toNumber: any) => export type FormValues = {
QUERY_LOG_INTERVALS_DAYS.map((interval) => ( enabled: boolean;
<Field anonymize_client_ip: boolean;
key={interval} interval: number;
name="interval" customInterval?: number | null;
type="radio" ignored: string;
component={renderRadioField} };
value={interval}
placeholder={getIntervalTitle(interval, t)}
normalize={toNumber}
disabled={processing}
/>
));
interface FormProps { type Props = {
handleSubmit: (...args: unknown[]) => string; initialValues: Partial<FormValues>;
handleClear: (...args: unknown[]) => unknown;
submitting: boolean;
invalid: boolean;
processing: boolean; processing: boolean;
processingClear: boolean; processingReset: boolean;
t: (...args: unknown[]) => string; onSubmit: (values: FormValues) => void;
interval?: number; onReset: () => void;
customInterval?: number; };
dispatch: (...args: unknown[]) => unknown;
} export const Form = ({ initialValues, processing, processingReset, onSubmit, onReset }: Props) => {
const { t } = useTranslation();
let Form = (props: FormProps) => {
const { const {
handleSubmit, handleSubmit,
submitting, watch,
invalid, setValue,
processing, control,
processingClear, formState: { isSubmitting },
handleClear, } = useForm<FormValues>({
t, mode: 'onBlur',
interval, defaultValues: {
customInterval, enabled: initialValues.enabled || false,
dispatch, anonymize_client_ip: initialValues.anonymize_client_ip || false,
} = props; interval: initialValues.interval || DAY,
customInterval: initialValues.customInterval || null,
ignored: initialValues.ignored || '',
},
});
const intervalValue = watch('interval');
const customIntervalValue = watch('customInterval');
useEffect(() => { useEffect(() => {
if (QUERY_LOG_INTERVALS_DAYS.includes(interval)) { if (QUERY_LOG_INTERVALS_DAYS.includes(intervalValue)) {
dispatch(change(FORM_NAME.LOG_CONFIG, CUSTOM_INTERVAL, null)); setValue('customInterval', null);
} }
}, [interval]); }, [intervalValue]);
const onSubmitForm = (data: FormValues) => {
onSubmit(data);
};
const handleIgnoredBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const trimmed = trimLinesAndRemoveEmpty(e.target.value);
setValue('ignored', trimmed);
};
const disableSubmit = isSubmitting || processing || (intervalValue === RETENTION_CUSTOM && !customIntervalValue);
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmitForm)}>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name="enabled" name="enabled"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('query_log_enable')} <Checkbox
disabled={processing} {...field}
data-testid="logs_enabled"
title={t('query_log_enable')}
disabled={processing}
/>
)}
/> />
</div> </div>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name="anonymize_client_ip" name="anonymize_client_ip"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('anonymize_client_ip')} <Checkbox
subtitle={t('anonymize_client_ip_desc')} {...field}
disabled={processing} data-testid="logs_anonymize_client_ip"
title={t('anonymize_client_ip')}
subtitle={t('anonymize_client_ip_desc')}
disabled={processing}
/>
)}
/> />
</div> </div>
<label className="form__label"> <div className="form__label">
<Trans>query_log_retention</Trans> <Trans>query_log_retention</Trans>
</label> </div>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<div className="custom-controls-stacked"> <div className="custom-controls-stacked">
<Field <label className="custom-control custom-radio">
key={RETENTION_CUSTOM} <input
name="interval" type="radio"
type="radio" data-testid="logs_config_interval"
component={renderRadioField} className="custom-control-input"
value={QUERY_LOG_INTERVALS_DAYS.includes(interval) ? RETENTION_CUSTOM : interval} disabled={processing}
placeholder={getIntervalTitle(RETENTION_CUSTOM, t)} checked={!QUERY_LOG_INTERVALS_DAYS.includes(intervalValue)}
normalize={toFloatNumber} value={RETENTION_CUSTOM}
disabled={processing} onChange={(e) => {
/> setValue('interval', parseInt(e.target.value, 10));
{!QUERY_LOG_INTERVALS_DAYS.includes(interval) && ( }}
/>
<span className="custom-control-label">{getIntervalTitle(RETENTION_CUSTOM)}</span>
</label>
{!QUERY_LOG_INTERVALS_DAYS.includes(intervalValue) && (
<div className="form__group--input"> <div className="form__group--input">
<div className="form__desc form__desc--top">{t('custom_rotation_input')}</div> <div className="form__desc form__desc--top">{t('custom_rotation_input')}</div>
<Field <Controller
key={RETENTION_CUSTOM_INPUT} name="customInterval"
name={CUSTOM_INTERVAL} control={control}
type="number" render={({ field, fieldState }) => (
className="form-control" <Input
component={renderInputField} {...field}
disabled={processing} data-testid="logs_config_custom_interval"
normalize={toFloatNumber} disabled={processing}
min={RETENTION_RANGE.MIN} error={fieldState.error?.message}
max={RETENTION_RANGE.MAX} min={RETENTION_RANGE.MIN}
max={RETENTION_RANGE.MAX}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
)} )}
{getIntervalFields(processing, t, toFloatNumber)}
{QUERY_LOG_INTERVALS_DAYS.map((interval) => (
<label key={interval} className="custom-control custom-radio">
<input
type="radio"
className="custom-control-input"
data-testid={`logs_config_${interval}`}
disabled={processing}
value={interval}
checked={intervalValue === interval}
onChange={(e) => {
setValue('interval', parseInt(e.target.value, 10));
}}
/>
<span className="custom-control-label">{getIntervalTitle(interval)}</span>
</label>
))}
</div> </div>
</div> </div>
@@ -154,51 +189,41 @@ let Form = (props: FormProps) => {
</div> </div>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name="ignored" name="ignored"
type="textarea" control={control}
className="form-control form-control--textarea font-monospace text-input" render={({ field, fieldState }) => (
component={renderTextareaField} <Textarea
placeholder={t('ignore_domains')} {...field}
disabled={processing} data-testid="logs_config_ingored"
normalizeOnBlur={trimLinesAndRemoveEmpty} placeholder={t('ignore_domains')}
className="text-input"
disabled={processing}
error={fieldState.error?.message}
onBlur={handleIgnoredBlur}
/>
)}
/> />
</div> </div>
<div className="mt-5"> <div className="mt-5">
<button <button
type="submit" type="submit"
data-testid="logs_config_save"
className="btn btn-success btn-standard btn-large" className="btn btn-success btn-standard btn-large"
disabled={ disabled={disableSubmit}>
submitting ||
invalid ||
processing ||
(!QUERY_LOG_INTERVALS_DAYS.includes(interval) && !customInterval)
}>
<Trans>save_btn</Trans> <Trans>save_btn</Trans>
</button> </button>
<button <button
type="button" type="button"
data-testid="logs_config_clear"
className="btn btn-outline-secondary btn-standard form__button" className="btn btn-outline-secondary btn-standard form__button"
onClick={() => handleClear()} onClick={onReset}
disabled={processingClear}> disabled={processingReset}>
<Trans>query_log_clear</Trans> <Trans>query_log_clear</Trans>
</button> </button>
</div> </div>
</form> </form>
); );
}; };
const selector = formValueSelector(FORM_NAME.LOG_CONFIG);
Form = connect((state) => {
const interval = selector(state, 'interval');
const customInterval = selector(state, CUSTOM_INTERVAL);
return {
interval,
customInterval,
};
})(Form);
export default flow([withTranslation(), reduxForm({ form: FORM_NAME.LOG_CONFIG })])(Form);

View File

@@ -3,7 +3,7 @@ import { withTranslation } from 'react-i18next';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import Form from './Form'; import { Form, FormValues } from './Form';
import { HOUR } from '../../../helpers/constants'; import { HOUR } from '../../../helpers/constants';
interface LogsConfigProps { interface LogsConfigProps {
@@ -20,7 +20,7 @@ interface LogsConfigProps {
} }
class LogsConfig extends Component<LogsConfigProps> { class LogsConfig extends Component<LogsConfigProps> {
handleFormSubmit = (values: any) => { handleFormSubmit = (values: FormValues) => {
const { t, interval: prevInterval } = this.props; const { t, interval: prevInterval } = this.props;
const { interval, customInterval, ...rest } = values; const { interval, customInterval, ...rest } = values;
@@ -53,19 +53,12 @@ class LogsConfig extends Component<LogsConfigProps> {
render() { render() {
const { const {
t, t,
enabled, enabled,
interval, interval,
processing, processing,
processingClear, processingClear,
anonymize_client_ip, anonymize_client_ip,
ignored, ignored,
customInterval, customInterval,
} = this.props; } = this.props;
@@ -80,10 +73,10 @@ class LogsConfig extends Component<LogsConfigProps> {
anonymize_client_ip, anonymize_client_ip,
ignored: ignored?.join('\n'), ignored: ignored?.join('\n'),
}} }}
onSubmit={this.handleFormSubmit}
processing={processing} processing={processing}
processingClear={processingClear} processingReset={processingClear}
handleClear={this.handleClear} onSubmit={this.handleFormSubmit}
onReset={this.handleClear}
/> />
</div> </div>
</Card> </Card>

View File

@@ -63,6 +63,7 @@
} }
.form__message { .form__message {
margin-top: 4px;
font-size: 11px; font-size: 11px;
} }
@@ -97,6 +98,10 @@
margin: 0 0 8px; margin: 0 0 8px;
} }
.form__label {
margin-bottom: 8px;
}
.form__label--bold { .form__label--bold {
font-weight: 700; font-weight: 700;
} }

View File

@@ -1,90 +1,101 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { change, Field, formValueSelector, reduxForm } from 'redux-form'; import { Trans, useTranslation } from 'react-i18next';
import { Trans, withTranslation } from 'react-i18next'; import i18next from 'i18next';
import flow from 'lodash/flow';
import { connect } from 'react-redux';
import { import { Controller, useForm } from 'react-hook-form';
renderRadioField, import { STATS_INTERVALS_DAYS, DAY, RETENTION_CUSTOM, RETENTION_RANGE } from '../../../helpers/constants';
toNumber,
CheckboxField,
renderTextareaField,
toFloatNumber,
renderInputField,
} from '../../../helpers/form';
import {
FORM_NAME,
STATS_INTERVALS_DAYS,
DAY,
RETENTION_CUSTOM,
RETENTION_CUSTOM_INPUT,
CUSTOM_INTERVAL,
RETENTION_RANGE,
} from '../../../helpers/constants';
import { trimLinesAndRemoveEmpty } from '../../../helpers/helpers';
import '../FormButton.css'; import '../FormButton.css';
import { Checkbox } from '../../ui/Controls/Checkbox';
import { Input } from '../../ui/Controls/Input';
import { toNumber } from '../../../helpers/form';
import { Textarea } from '../../ui/Controls/Textarea';
const getIntervalTitle = (intervalMs: any, t: any) => { const getIntervalTitle = (interval: any) => {
switch (intervalMs) { switch (interval) {
case RETENTION_CUSTOM: case RETENTION_CUSTOM:
return t('settings_custom'); return i18next.t('settings_custom');
case DAY: case DAY:
return t('interval_24_hour'); return i18next.t('interval_24_hour');
default: default:
return t('interval_days', { count: intervalMs / DAY }); return i18next.t('interval_days', { count: interval / DAY });
} }
}; };
interface FormProps { export type FormValues = {
handleSubmit: (...args: unknown[]) => string; enabled: boolean;
handleReset: (...args: unknown[]) => string; interval: number;
change: (...args: unknown[]) => unknown; customInterval?: number | null;
submitting: boolean; ignored: string;
invalid: boolean; };
const defaultFormValues = {
enabled: false,
interval: DAY,
customInterval: null,
ignored: '',
};
type Props = {
initialValues: FormValues;
processing: boolean; processing: boolean;
processingReset: boolean; processingReset: boolean;
t: (...args: unknown[]) => string; onSubmit: (values: FormValues) => void;
interval?: number; onReset: () => void;
customInterval?: number; };
dispatch: (...args: unknown[]) => unknown;
} export const Form = ({ initialValues, processing, processingReset, onSubmit, onReset }: Props) => {
const { t } = useTranslation();
let Form = (props: FormProps) => {
const { const {
handleSubmit, handleSubmit,
processing, watch,
submitting, setValue,
invalid, control,
handleReset, formState: { isSubmitting },
processingReset, } = useForm<FormValues>({
t, mode: 'onBlur',
interval, defaultValues: {
customInterval, ...defaultFormValues,
dispatch, ...initialValues,
} = props; },
});
const intervalValue = watch('interval');
const customIntervalValue = watch('customInterval');
useEffect(() => { useEffect(() => {
if (STATS_INTERVALS_DAYS.includes(interval)) { if (STATS_INTERVALS_DAYS.includes(intervalValue)) {
dispatch(change(FORM_NAME.STATS_CONFIG, CUSTOM_INTERVAL, null)); setValue('customInterval', null);
} }
}, [interval]); }, [intervalValue]);
const onSubmitForm = (data: FormValues) => {
onSubmit(data);
};
const disableSubmit = isSubmitting || processing || (intervalValue === RETENTION_CUSTOM && !customIntervalValue);
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit(onSubmitForm)}>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name="enabled" name="enabled"
type="checkbox" control={control}
component={CheckboxField} render={({ field }) => (
placeholder={t('statistics_enable')} <Checkbox
disabled={processing} {...field}
data-testid="stats_config_enabled"
title={t('statistics_enable')}
disabled={processing}
/>
)}
/> />
</div> </div>
<label className="form__label form__label--with-desc"> <div className="form__label form__label--with-desc">
<Trans>statistics_retention</Trans> <Trans>statistics_retention</Trans>
</label> </div>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">
<Trans>statistics_retention_desc</Trans> <Trans>statistics_retention_desc</Trans>
@@ -92,85 +103,105 @@ let Form = (props: FormProps) => {
<div className="form__group form__group--settings mt-2"> <div className="form__group form__group--settings mt-2">
<div className="custom-controls-stacked"> <div className="custom-controls-stacked">
<Field <label className="custom-control custom-radio">
key={RETENTION_CUSTOM} <input
name="interval" type="radio"
type="radio" data-testid="stats_config_interval"
component={renderRadioField} className="custom-control-input"
value={STATS_INTERVALS_DAYS.includes(interval) ? RETENTION_CUSTOM : interval} disabled={processing}
placeholder={getIntervalTitle(RETENTION_CUSTOM, t)} checked={!STATS_INTERVALS_DAYS.includes(intervalValue)}
normalize={toFloatNumber} value={RETENTION_CUSTOM}
disabled={processing} onChange={(e) => {
/> setValue('interval', parseInt(e.target.value, 10));
{!STATS_INTERVALS_DAYS.includes(interval) && ( }}
<div className="form__group--input"> />
<div className="form__desc form__desc--top">{t('custom_retention_input')}</div>
<Field <span className="custom-control-label">{getIntervalTitle(RETENTION_CUSTOM)}</span>
key={RETENTION_CUSTOM_INPUT} </label>
name={CUSTOM_INTERVAL}
type="number" {!STATS_INTERVALS_DAYS.includes(intervalValue) && (
className="form-control" <div className="form__group--input">
component={renderInputField} <div className="form__desc form__desc--top">{i18next.t('custom_retention_input')}</div>
disabled={processing}
normalize={toFloatNumber} <Controller
min={RETENTION_RANGE.MIN} name="customInterval"
max={RETENTION_RANGE.MAX} control={control}
render={({ field, fieldState }) => (
<Input
{...field}
data-testid="stats_config_custom_interval"
disabled={processing}
error={fieldState.error?.message}
min={RETENTION_RANGE.MIN}
max={RETENTION_RANGE.MAX}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
)} )}
{STATS_INTERVALS_DAYS.map((interval) => ( {STATS_INTERVALS_DAYS.map((interval) => (
<Field <label key={interval} className="custom-control custom-radio">
key={interval} <input
name="interval" type="radio"
type="radio" className="custom-control-input"
component={renderRadioField} disabled={processing}
value={interval} value={interval}
placeholder={getIntervalTitle(interval, t)} checked={intervalValue === interval}
normalize={toNumber} onChange={(e) => {
disabled={processing} setValue('interval', parseInt(e.target.value, 10));
/> }}
/>
<span className="custom-control-label">{getIntervalTitle(interval)}</span>
</label>
))} ))}
</div> </div>
</div> </div>
<label className="form__label form__label--with-desc"> <div className="form__label form__label--with-desc">
<Trans>ignore_domains_title</Trans> <Trans>ignore_domains_title</Trans>
</label> </div>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">
<Trans>ignore_domains_desc_stats</Trans> <Trans>ignore_domains_desc_stats</Trans>
</div> </div>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<Field <Controller
name="ignored" name="ignored"
type="textarea" control={control}
className="form-control form-control--textarea font-monospace text-input" render={({ field, fieldState }) => (
component={renderTextareaField} <Textarea
placeholder={t('ignore_domains')} {...field}
disabled={processing} data-testid="stats_config_ignored"
normalizeOnBlur={trimLinesAndRemoveEmpty} placeholder={t('ignore_domains')}
className="text-input"
disabled={processing}
error={fieldState.error?.message}
trimOnBlur
/>
)}
/> />
</div> </div>
<div className="mt-5"> <div className="mt-5">
<button <button
type="submit" type="submit"
data-testid="stats_config_save"
className="btn btn-success btn-standard btn-large" className="btn btn-success btn-standard btn-large"
disabled={ disabled={disableSubmit}>
submitting ||
invalid ||
processing ||
(!STATS_INTERVALS_DAYS.includes(interval) && !customInterval)
}>
<Trans>save_btn</Trans> <Trans>save_btn</Trans>
</button> </button>
<button <button
type="button" type="button"
data-testid="stats_config_clear"
className="btn btn-outline-secondary btn-standard form__button" className="btn btn-outline-secondary btn-standard form__button"
onClick={() => handleReset()} onClick={onReset}
disabled={processingReset}> disabled={processingReset}>
<Trans>statistics_clear</Trans> <Trans>statistics_clear</Trans>
</button> </button>
@@ -178,16 +209,3 @@ let Form = (props: FormProps) => {
</form> </form>
); );
}; };
const selector = formValueSelector(FORM_NAME.STATS_CONFIG);
Form = connect((state) => {
const interval = selector(state, 'interval');
const customInterval = selector(state, CUSTOM_INTERVAL);
return {
interval,
customInterval,
};
})(Form);
export default flow([withTranslation(), reduxForm({ form: FORM_NAME.STATS_CONFIG })])(Form);

View File

@@ -3,7 +3,7 @@ import { withTranslation } from 'react-i18next';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import Form from './Form'; import { Form, FormValues } from './Form';
import { HOUR } from '../../../helpers/constants'; import { HOUR } from '../../../helpers/constants';
interface StatsConfigProps { interface StatsConfigProps {
@@ -19,7 +19,7 @@ interface StatsConfigProps {
} }
class StatsConfig extends Component<StatsConfigProps> { class StatsConfig extends Component<StatsConfigProps> {
handleFormSubmit = ({ enabled, interval, ignored, customInterval }: any) => { handleFormSubmit = ({ enabled, interval, ignored, customInterval }: FormValues) => {
const { t, interval: prevInterval } = this.props; const { t, interval: prevInterval } = this.props;
const newInterval = customInterval ? customInterval * HOUR : interval; const newInterval = customInterval ? customInterval * HOUR : interval;
@@ -49,17 +49,11 @@ class StatsConfig extends Component<StatsConfigProps> {
render() { render() {
const { const {
t, t,
interval, interval,
customInterval, customInterval,
processing, processing,
processingReset, processingReset,
ignored, ignored,
enabled, enabled,
} = this.props; } = this.props;
@@ -73,10 +67,10 @@ class StatsConfig extends Component<StatsConfigProps> {
enabled, enabled,
ignored: ignored.join('\n'), ignored: ignored.join('\n'),
}} }}
onSubmit={this.handleFormSubmit}
processing={processing} processing={processing}
processingReset={processingReset} processingReset={processingReset}
handleReset={this.handleReset} onSubmit={this.handleFormSubmit}
onReset={this.handleReset}
/> />
</div> </div>
</Card> </Card>

View File

@@ -1,13 +1,14 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import i18next from 'i18next';
import StatsConfig from './StatsConfig'; import StatsConfig from './StatsConfig';
import LogsConfig from './LogsConfig'; import LogsConfig from './LogsConfig';
import FiltersConfig from './FiltersConfig'; import { FiltersConfig } from './FiltersConfig';
import Checkbox from '../ui/Checkbox'; import { Checkbox } from '../ui/Controls/Checkbox';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
@@ -24,14 +25,14 @@ const ORDER_KEY = 'order';
const SETTINGS = { const SETTINGS = {
safebrowsing: { safebrowsing: {
enabled: false, enabled: false,
title: 'use_adguard_browsing_sec', title: i18next.t('use_adguard_browsing_sec'),
subtitle: 'use_adguard_browsing_sec_hint', subtitle: i18next.t('use_adguard_browsing_sec_hint'),
[ORDER_KEY]: 0, [ORDER_KEY]: 0,
}, },
parental: { parental: {
enabled: false, enabled: false,
title: 'use_adguard_parental', title: i18next.t('use_adguard_parental'),
subtitle: 'use_adguard_parental_hint', subtitle: i18next.t('use_adguard_parental_hint'),
[ORDER_KEY]: 1, [ORDER_KEY]: 1,
}, },
}; };
@@ -89,9 +90,18 @@ class Settings extends Component<SettingsProps> {
renderSettings = (settings: any) => renderSettings = (settings: any) =>
getObjectKeysSorted(SETTINGS, ORDER_KEY).map((key: any) => { getObjectKeysSorted(SETTINGS, ORDER_KEY).map((key: any) => {
const setting = settings[key]; const setting = settings[key];
const { enabled } = setting; const { enabled, title, subtitle } = setting;
return <Checkbox {...setting} key={key} handleChange={() => this.props.toggleSetting(key, enabled)} />; return (
<div key={key} className="form__group form__group--checkbox">
<Checkbox
value={enabled}
title={title}
subtitle={subtitle}
onChange={(checked) => this.props.toggleSetting(key, !checked)}
/>
</div>
);
}); });
renderSafeSearch = () => { renderSafeSearch = () => {
@@ -106,27 +116,29 @@ class Settings extends Component<SettingsProps> {
return ( return (
<> <>
<Checkbox <div className="form__group form__group--checkbox">
enabled={enabled} <Checkbox
title="enforce_safe_search" value={enabled}
subtitle="enforce_save_search_hint" title={i18next.t('enforce_safe_search')}
handleChange={({ target: { checked: enabled } }) => subtitle={i18next.t('enforce_save_search_hint')}
this.props.toggleSetting('safesearch', { ...safesearch, enabled }) onChange={(checked) =>
} this.props.toggleSetting('safesearch', { ...safesearch, enabled: checked })
/> }
/>
</div>
<div className="form__group--inner"> <div className="form__group--inner">
{Object.keys(searches).map((searchKey) => ( {Object.keys(searches).map((searchKey) => (
<Checkbox <div key={searchKey} className="form__group form__group--checkbox">
key={searchKey} <Checkbox
enabled={searches[searchKey]} value={searches[searchKey]}
title={captitalizeWords(searchKey)} title={captitalizeWords(searchKey)}
subtitle="" disabled={!safesearch.enabled}
disabled={!safesearch.enabled} onChange={(checked) =>
handleChange={({ target: { checked } }: any) => this.props.toggleSetting('safesearch', { ...safesearch, [searchKey]: checked })
this.props.toggleSetting('safesearch', { ...safesearch, [searchKey]: checked }) }
} />
/> </div>
))} ))}
</div> </div>
</> </>
@@ -136,23 +148,14 @@ class Settings extends Component<SettingsProps> {
render() { render() {
const { const {
settings, settings,
setStatsConfig, setStatsConfig,
resetStats, resetStats,
stats, stats,
queryLogs, queryLogs,
setLogsConfig, setLogsConfig,
clearLogs, clearLogs,
filtering, filtering,
setFiltersConfig, setFiltersConfig,
t, t,
} = this.props; } = this.props;
@@ -163,6 +166,7 @@ class Settings extends Component<SettingsProps> {
<PageTitle title={t('general_settings')} /> <PageTitle title={t('general_settings')} />
{!isDataReady && <Loading />} {!isDataReady && <Loading />}
{isDataReady && ( {isDataReady && (
<div className="content"> <div className="content">
<div className="row"> <div className="row">

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Trans, withTranslation } from 'react-i18next'; import { Trans, withTranslation } from 'react-i18next';
import Guide from '../ui/Guide'; import { Guide } from '../ui/Guide';
import Card from '../ui/Card'; import Card from '../ui/Card';
@@ -14,10 +14,7 @@ interface SetupGuideProps {
t: (id: string) => string; t: (id: string) => string;
} }
const SetupGuide = ({ const SetupGuide = ({ t, dashboard: { dnsAddresses } }: SetupGuideProps) => (
t,
dashboard: { dnsAddresses },
}: SetupGuideProps) => (
<div className="guide"> <div className="guide">
<PageTitle title={t('setup_guide')} /> <PageTitle title={t('setup_guide')} />

View File

@@ -1,59 +0,0 @@
import React, { Component } from 'react';
import { withTranslation } from 'react-i18next';
import './Checkbox.css';
interface CheckboxProps {
title: string;
subtitle: string;
enabled: boolean;
handleChange: (...args: unknown[]) => unknown;
disabled?: boolean;
t?: (...args: unknown[]) => string;
}
class Checkbox extends Component<CheckboxProps> {
render() {
const {
title,
subtitle,
enabled,
handleChange,
disabled,
t,
} = this.props;
return (
<div className="form__group form__group--checkbox">
<label className="checkbox checkbox--settings">
<span className="checkbox__marker" />
<input
type="checkbox"
className="checkbox__input"
onChange={handleChange}
checked={enabled}
disabled={disabled}
/>
<span className="checkbox__label">
<span className="checkbox__label-text">
<span className="checkbox__label-title">{t(title)}</span>
<span
className="checkbox__label-subtitle"
dangerouslySetInnerHTML={{ __html: t(subtitle) }}
/>
</span>
</span>
</label>
</div>
);
}
}
export default withTranslation()(Checkbox);

View File

@@ -0,0 +1,50 @@
import React, { forwardRef, ReactNode } from 'react';
import clsx from 'clsx';
import './checkbox.css';
type Props = {
title: string;
subtitle?: ReactNode;
value: boolean;
name?: string;
disabled?: boolean;
className?: string;
error?: string;
onChange: (value: boolean) => void;
onBlur?: () => void;
};
export const Checkbox = forwardRef<HTMLInputElement, Props>(
(
{ title, subtitle, value, name, disabled, error, className = 'checkbox--form', onChange, onBlur, ...rest },
ref,
) => (
<>
<label className={clsx('checkbox', className)}>
<span className="checkbox__marker" />
<input
name={name}
type="checkbox"
className="checkbox__input"
disabled={disabled}
checked={value}
onChange={(e) => onChange(e.target.checked)}
onBlur={onBlur}
ref={ref}
{...rest}
/>
<span className="checkbox__label">
<span className="checkbox__label-text checkbox__label-text--long">
<span className="checkbox__label-title">{title}</span>
{subtitle && <span className="checkbox__label-subtitle">{subtitle}</span>}
</span>
</span>
</label>
{error && <div className="form__message form__message--error">{error}</div>}
</>
),
);
Checkbox.displayName = 'Checkbox';

View File

@@ -0,0 +1,45 @@
import React, { ComponentProps, forwardRef, ReactNode } from 'react';
import clsx from 'clsx';
type Props = ComponentProps<'input'> & {
label?: string;
desc?: string;
leftAddon?: ReactNode;
rightAddon?: ReactNode;
error?: string;
trimOnBlur?: boolean;
};
export const Input = forwardRef<HTMLInputElement, Props>(
({ name, label, desc, className, leftAddon, rightAddon, error, trimOnBlur, onBlur, ...rest }, ref) => (
<div className={clsx('form-group', { 'has-error': !!error })}>
{label && (
<label className={clsx('form__label', { 'form__label--with-desc': !!desc })} htmlFor={name}>
{label}
</label>
)}
{desc && <div className="form__desc form__desc--top">{desc}</div>}
<div className="input-group">
{leftAddon && <div>{leftAddon}</div>}
<input
className={clsx('form-control', { 'is-invalid': !!error }, className)}
ref={ref}
onBlur={(e) => {
if (trimOnBlur) {
e.target.value = e.target.value.trim();
rest.onChange(e);
}
if (onBlur) {
onBlur(e);
}
}}
{...rest}
/>
{rightAddon && <div>{rightAddon}</div>}
</div>
{error && <div className="form__message form__message--error mt-1">{error}</div>}
</div>
),
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,50 @@
import React, { forwardRef, ReactNode } from 'react';
type Props<T> = {
name: string;
value: T;
onChange: (e: T) => void;
options: { label: string; desc?: ReactNode; value: T }[];
disabled?: boolean;
error?: string;
};
export const Radio = forwardRef<HTMLInputElement, Props<string | boolean | number | undefined>>(
({ disabled, onChange, value, options, name, error, ...rest }, ref) => {
const getId = (label: string) => (name ? `${label}_${name}` : label);
return (
<div>
{options.map((o) => {
const checked = value === o.value;
return (
<label
key={`${getId(o.label)}`}
htmlFor={getId(o.label)}
className="custom-control custom-radio">
<input
id={getId(o.label)}
data-testid={o.value}
type="radio"
className="custom-control-input"
onChange={() => onChange(o.value)}
checked={checked}
disabled={disabled}
ref={ref}
{...rest}
/>
<span className="custom-control-label">{o.label}</span>
{o.desc && <span className="checkbox__label-subtitle">{o.desc}</span>}
</label>
);
})}
{!disabled && error && <span className="form__message form__message--error">{error}</span>}
</div>
);
},
);
Radio.displayName = 'Radio';

View File

@@ -0,0 +1,27 @@
import React, { ComponentProps, forwardRef } from 'react';
import clsx from 'clsx';
type SelectProps = ComponentProps<'select'> & {
label?: string;
error?: string;
};
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ name, label, className, error, children, ...rest }, ref) => (
<div className={clsx('form-group', { 'has-error': !!error })}>
{label && (
<label className="form__label" htmlFor={name}>
{label}
</label>
)}
<div className="input-group">
<select className={clsx('form-control custom-select', className)} ref={ref} {...rest}>
{children}
</select>
</div>
{error && <div className="form__message form__message--error mt-1">{error}</div>}
</div>
),
);
Select.displayName = 'Select';

View File

@@ -0,0 +1,45 @@
import React, { ComponentProps, forwardRef } from 'react';
import clsx from 'clsx';
import { trimLinesAndRemoveEmpty } from '../../../helpers/helpers';
type Props = ComponentProps<'textarea'> & {
className?: string;
wrapperClassName?: string;
label?: string;
desc?: string;
error?: string;
trimOnBlur?: boolean;
};
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(
({ name, label, desc, className, wrapperClassName, error, trimOnBlur, onBlur, ...rest }, ref) => (
<div className={clsx('form-group', wrapperClassName, { 'has-error': !!error })}>
{label && (
<label className={clsx('form__label', { 'form__label--with-desc': !!desc })} htmlFor={name}>
{label}
</label>
)}
{desc && <div className="form__desc form__desc--top">{desc}</div>}
<textarea
className={clsx(
'form-control form-control--textarea form-control--textarea-small font-monospace',
className,
)}
ref={ref}
onBlur={(e) => {
if (trimOnBlur) {
const normalizedValue = trimLinesAndRemoveEmpty(e.target.value);
rest.onChange(normalizedValue);
}
if (onBlur) {
onBlur(e);
}
}}
{...rest}
/>
{error && <div className="form__message form__message--error">{error}</div>}
</div>
),
);
Textarea.displayName = 'Textarea';

View File

@@ -7,7 +7,7 @@ import { MOBILE_CONFIG_LINKS } from '../../../helpers/constants';
import Tabs from '../Tabs'; import Tabs from '../Tabs';
import MobileConfigForm from './MobileConfigForm'; import { MobileConfigForm } from './MobileConfigForm';
import { RootState } from '../../../initialState'; import { RootState } from '../../../initialState';
interface renderLiProps { interface renderLiProps {
@@ -346,7 +346,7 @@ interface GuideProps {
dnsAddresses?: unknown[]; dnsAddresses?: unknown[];
} }
const Guide = ({ dnsAddresses }: GuideProps) => { export const Guide = ({ dnsAddresses }: GuideProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const serverName = useSelector((state: RootState) => state.encryption?.server_name); const serverName = useSelector((state: RootState) => state.encryption?.server_name);
@@ -381,5 +381,3 @@ const Guide = ({ dnsAddresses }: GuideProps) => {
Guide.defaultProps = { Guide.defaultProps = {
dnsAddresses: [], dnsAddresses: [],
}; };
export default Guide;

View File

@@ -1,32 +1,31 @@
import React from 'react'; import React from 'react';
import { Trans } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux'; import { Controller, useForm } from 'react-hook-form';
import { Field, reduxForm } from 'redux-form';
import i18next from 'i18next'; import i18next from 'i18next';
import cn from 'classnames'; import cn from 'classnames';
import { getPathWithQueryString } from '../../../helpers/helpers'; import { getPathWithQueryString } from '../../../helpers/helpers';
import { CLIENT_ID_LINK, FORM_NAME, MOBILE_CONFIG_LINKS, STANDARD_HTTPS_PORT } from '../../../helpers/constants'; import { CLIENT_ID_LINK, MOBILE_CONFIG_LINKS, STANDARD_HTTPS_PORT } from '../../../helpers/constants';
import { renderInputField, renderSelectField, toNumber } from '../../../helpers/form'; import { toNumber } from '../../../helpers/form';
import { import {
validateConfigClientId, validateConfigClientId,
validateServerName, validateServerName,
validatePort, validatePort,
validateIsSafePort, validateIsSafePort,
} from '../../../helpers/validators'; } from '../../../helpers/validators';
import { RootState } from '../../../initialState'; import { Input } from '../Controls/Input';
import { Select } from '../Controls/Select';
const getDownloadLink = (host: any, clientId: any, protocol: any, invalid: any) => { const getDownloadLink = (host: string, clientId: string, protocol: string, invalid: boolean) => {
if (!host || invalid) { if (!host || invalid) {
return ( return (
<button type="button" className="btn btn-success btn-standard btn-large disabled"> <button type="button" className="btn btn-success btn-standard btn-large disabled">
<Trans>download_mobileconfig</Trans> {i18next.t('download_mobileconfig')}
</button> </button>
); );
} }
const linkParams: { host: string, client_id?: string } = { host }; const linkParams: { host: string; client_id?: string } = { host };
if (clientId) { if (clientId) {
linkParams.client_id = clientId; linkParams.client_id = clientId;
@@ -37,29 +36,48 @@ const getDownloadLink = (host: any, clientId: any, protocol: any, invalid: any)
href={getPathWithQueryString(protocol, linkParams)} href={getPathWithQueryString(protocol, linkParams)}
className={cn('btn btn-success btn-standard btn-large')} className={cn('btn btn-success btn-standard btn-large')}
download> download>
<Trans>download_mobileconfig</Trans> {i18next.t('download_mobileconfig')}
</a> </a>
); );
}; };
interface MobileConfigFormProps { type FormValues = {
invalid: boolean; host: string;
} clientId: string;
protocol: string;
port?: number;
};
const MobileConfigForm = ({ invalid }: MobileConfigFormProps) => { type Props = {
const formValues = useSelector((state: RootState) => state.form[FORM_NAME.MOBILE_CONFIG]?.values); initialValues?: FormValues;
};
if (!formValues) { const defaultFormValues = {
return null; host: '',
} clientId: '',
protocol: MOBILE_CONFIG_LINKS.DOT,
port: undefined,
};
const { host, clientId, protocol, port } = formValues; export const MobileConfigForm = ({ initialValues }: Props) => {
const { t } = useTranslation();
const githubLink = ( const {
<a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer"> watch,
text control,
</a> formState: { isValid },
); } = useForm<FormValues>({
mode: 'onBlur',
defaultValues: {
...defaultFormValues,
...initialValues,
},
});
const protocol = watch('protocol');
const host = watch('host');
const clientId = watch('clientId');
const port = watch('port');
const getHostName = () => { const getHostName = () => {
if (port && port !== STANDARD_HTTPS_PORT && protocol === MOBILE_CONFIG_LINKS.DOH) { if (port && port !== STANDARD_HTTPS_PORT && protocol === MOBILE_CONFIG_LINKS.DOH) {
@@ -75,33 +93,47 @@ const MobileConfigForm = ({ invalid }: MobileConfigFormProps) => {
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<div className="row"> <div className="row">
<div className="col"> <div className="col">
<label htmlFor="host" className="form__label"> <Controller
{i18next.t('dhcp_table_hostname')}
</label>
<Field
name="host" name="host"
type="text" control={control}
component={renderInputField} rules={{ validate: validateServerName }}
className="form-control" render={({ field, fieldState }) => (
placeholder={i18next.t('form_enter_hostname')} <Input
validate={validateServerName} {...field}
type="text"
data-testid="mobile_config_host"
label={t('dhcp_table_hostname')}
placeholder={t('form_enter_hostname')}
error={fieldState.error?.message}
/>
)}
/> />
</div> </div>
{protocol === MOBILE_CONFIG_LINKS.DOH && ( {protocol === MOBILE_CONFIG_LINKS.DOH && (
<div className="col"> <div className="col">
<label htmlFor="port" className="form__label"> <Controller
{i18next.t('encryption_https')}
</label>
<Field
name="port" name="port"
type="number" control={control}
component={renderInputField} rules={{
className="form-control" validate: {
placeholder={i18next.t('encryption_https')} range: (value) => validatePort(value) || true,
validate={[validatePort, validateIsSafePort]} safety: (value) => validateIsSafePort(value) || true,
normalize={toNumber} },
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
data-testid="mobile_config_port"
label={t('encryption_https')}
placeholder={t('encryption_https')}
error={fieldState.error?.message}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/> />
</div> </div>
)} )}
@@ -110,39 +142,49 @@ const MobileConfigForm = ({ invalid }: MobileConfigFormProps) => {
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label htmlFor="clientId" className="form__label form__label--with-desc"> <label htmlFor="clientId" className="form__label form__label--with-desc">
{i18next.t('client_id')} {t('client_id')}
</label> </label>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">
<Trans components={{ a: githubLink }}>client_id_desc</Trans> <Trans
components={{ a: <a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer" /> }}>
client_id_desc
</Trans>
</div> </div>
<Field <Controller
name="clientId" name="clientId"
type="text" control={control}
component={renderInputField} rules={{
className="form-control" validate: validateConfigClientId,
placeholder={i18next.t('client_id_placeholder')} }}
validate={validateConfigClientId} render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="mobile_config_client_id"
placeholder={t('client_id_placeholder')}
error={fieldState.error?.message}
/>
)}
/> />
</div> </div>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label htmlFor="protocol" className="form__label"> <Controller
{i18next.t('protocol')} name="protocol"
</label> control={control}
render={({ field }) => (
<Field name="protocol" type="text" component={renderSelectField} className="form-control"> <Select {...field} label={t('protocol')} data-testid="mobile_config_protocol">
<option value={MOBILE_CONFIG_LINKS.DOT}>{i18next.t('dns_over_tls')}</option> <option value={MOBILE_CONFIG_LINKS.DOT}>{t('dns_over_tls')}</option>
<option value={MOBILE_CONFIG_LINKS.DOH}>{t('dns_over_https')}</option>
<option value={MOBILE_CONFIG_LINKS.DOH}>{i18next.t('dns_over_https')}</option> </Select>
</Field> )}
/>
</div> </div>
</div> </div>
{getDownloadLink(getHostName(), clientId, protocol, invalid)} {getDownloadLink(getHostName(), clientId, protocol, !isValid)}
</form> </form>
); );
}; };
export default reduxForm({ form: FORM_NAME.MOBILE_CONFIG })(MobileConfigForm);

View File

@@ -1 +1 @@
export { default } from './Guide'; export * from './Guide';

View File

@@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { toggleProtection, getClients } from '../actions'; import { toggleProtection, getClients } from '../actions';
import { getStats, getStatsConfig, setStatsConfig } from '../actions/stats'; import { getStats, getStatsConfig } from '../actions/stats';
import { getAccessList } from '../actions/access'; import { getAccessList } from '../actions/access';
import Dashboard from '../components/Dashboard'; import Dashboard from '../components/Dashboard';
@@ -19,7 +19,7 @@ type DispatchProps = {
getStats: (...args: unknown[]) => unknown; getStats: (...args: unknown[]) => unknown;
getStatsConfig: (...args: unknown[]) => unknown; getStatsConfig: (...args: unknown[]) => unknown;
getAccessList: () => (dispatch: any) => void; getAccessList: () => (dispatch: any) => void;
} };
const mapDispatchToProps: DispatchProps = { const mapDispatchToProps: DispatchProps = {
toggleProtection, toggleProtection,

View File

@@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getTlsStatus, setTlsConfig, validateTlsConfig } from '../actions/encryption'; import { getTlsStatus, setTlsConfig, validateTlsConfig } from '../actions/encryption';
import Encryption from '../components/Settings/Encryption'; import { Encryption } from '../components/Settings/Encryption';
const mapStateToProps = (state: any) => { const mapStateToProps = (state: any) => {
const { encryption } = state; const { encryption } = state;

View File

@@ -91,6 +91,7 @@ export const STANDARD_WEB_PORT = 80;
export const STANDARD_HTTPS_PORT = 443; export const STANDARD_HTTPS_PORT = 443;
export const DNS_OVER_TLS_PORT = 853; export const DNS_OVER_TLS_PORT = 853;
export const DNS_OVER_QUIC_PORT = 853; export const DNS_OVER_QUIC_PORT = 853;
export const MIN_PORT = 1;
export const MAX_PORT = 65535; export const MAX_PORT = 65535;
export const EMPTY_DATE = '0001-01-01T00:00:00Z'; export const EMPTY_DATE = '0001-01-01T00:00:00Z';
@@ -209,7 +210,7 @@ export const WHOIS_ICONS = {
export const DEFAULT_LOGS_FILTER = { export const DEFAULT_LOGS_FILTER = {
search: '', search: '',
response_status: '', response_status: 'all',
}; };
export const DEFAULT_LANGUAGE = 'en'; export const DEFAULT_LANGUAGE = 'en';

View File

@@ -1,304 +1,5 @@
import React, { Fragment } from 'react';
import { Trans } from 'react-i18next';
import cn from 'classnames';
import { createOnBlurHandler } from './helpers';
import { R_MAC_WITHOUT_COLON, R_UNIX_ABSOLUTE_PATH, R_WIN_ABSOLUTE_PATH } from './constants'; import { R_MAC_WITHOUT_COLON, R_UNIX_ABSOLUTE_PATH, R_WIN_ABSOLUTE_PATH } from './constants';
interface renderFieldProps {
id: string;
input: object;
className?: string;
placeholder?: string;
type?: string;
disabled?: boolean;
autoComplete?: string;
normalizeOnBlur?: (...args: unknown[]) => unknown;
min?: number;
max?: number;
step?: number;
onScroll?: (...args: unknown[]) => unknown;
meta: {
touched?: boolean;
error?: string;
};
}
export const renderField = (props: renderFieldProps, elementType: any) => {
const {
input,
id,
className,
placeholder,
type,
disabled,
normalizeOnBlur,
onScroll,
autoComplete,
meta: { touched, error },
min,
max,
step,
} = props;
const onBlur = (event: any) => createOnBlurHandler(event, input, normalizeOnBlur);
const element = React.createElement(elementType, {
...input,
id,
className,
placeholder,
autoComplete,
disabled,
type,
min,
max,
step,
onBlur,
onScroll,
});
return (
<>
{element}
{!disabled && touched && error && (
<span className="form__message form__message--error">
<Trans>{error}</Trans>
</span>
)}
</>
);
};
export const renderTextareaField = (props: any) => renderField(props, 'textarea');
export const renderInputField = (props: any) => renderField(props, 'input');
interface renderGroupFieldProps {
input: object;
id?: string;
className?: string;
placeholder?: string;
type?: string;
disabled?: boolean;
autoComplete?: string;
isActionAvailable?: boolean;
removeField?: (...args: unknown[]) => unknown;
meta: {
touched?: boolean;
error?: string;
};
normalizeOnBlur?: (...args: unknown[]) => unknown;
}
export const renderGroupField = ({
input,
id,
className,
placeholder,
type,
disabled,
autoComplete,
isActionAvailable,
removeField,
meta: { touched, error },
normalizeOnBlur,
}: renderGroupFieldProps) => {
const onBlur = (event: any) => createOnBlurHandler(event, input, normalizeOnBlur);
return (
<>
<div className="input-group">
<input
{...input}
id={id}
placeholder={placeholder}
type={type}
className={className}
disabled={disabled}
autoComplete={autoComplete}
onBlur={onBlur}
/>
{isActionAvailable && (
<span className="input-group-append">
<button
type="button"
className="btn btn-secondary btn-icon btn-icon--green"
onClick={removeField}>
<svg className="icon icon--24">
<use xlinkHref="#cross" />
</svg>
</button>
</span>
)}
</div>
{!disabled && touched && error && (
<span className="form__message form__message--error">
<Trans>{error}</Trans>
</span>
)}
</>
);
};
interface renderRadioFieldProps {
input: object;
placeholder?: string;
subtitle?: string;
disabled?: boolean;
meta: {
touched?: boolean;
error?: string;
};
}
export const renderRadioField = ({
input,
placeholder,
subtitle,
disabled,
meta: { touched, error },
}: renderRadioFieldProps) => (
<Fragment>
<label className="custom-control custom-radio">
<input {...input} type="radio" className="custom-control-input" disabled={disabled} />
<span className="custom-control-label">{placeholder}</span>
{subtitle && <span className="checkbox__label-subtitle" dangerouslySetInnerHTML={{ __html: subtitle }} />}
</label>
{!disabled && touched && error && (
<span className="form__message form__message--error">
<Trans>{error}</Trans>
</span>
)}
</Fragment>
);
interface CheckboxFieldProps {
input: object;
placeholder?: string;
subtitle?: React.ReactNode;
disabled?: boolean;
onClick?: (...args: unknown[]) => unknown;
modifier?: string;
checked?: boolean;
meta: {
touched?: boolean;
error?: string;
};
}
export const CheckboxField = ({
input,
placeholder,
subtitle,
disabled,
onClick,
modifier = 'checkbox--form',
meta: { touched, error },
}: CheckboxFieldProps) => (
<>
<label className={`checkbox ${modifier}`} onClick={onClick}>
<span className="checkbox__marker" />
<input {...input} type="checkbox" className="checkbox__input" disabled={disabled} />
<span className="checkbox__label">
<span className="checkbox__label-text checkbox__label-text--long">
<span className="checkbox__label-title">{placeholder}</span>
{subtitle && <span className="checkbox__label-subtitle">{subtitle}</span>}
</span>
</span>
</label>
{!disabled && touched && error && (
<div className="form__message form__message--error mt-1">
<Trans>{error}</Trans>
</div>
)}
</>
);
interface renderSelectFieldProps {
input: object;
disabled?: boolean;
label?: string;
children: unknown[] | React.ReactElement;
meta: {
touched?: boolean;
error?: string;
};
}
export const renderSelectField = ({ input, meta: { touched, error }, children, label }: renderSelectFieldProps) => {
const showWarning = touched && error;
return (
<>
{label && (
<label>
<Trans>{label}</Trans>
</label>
)}
<select {...input} className="form-control custom-select">
{children}
</select>
{showWarning && (
<span className="form__message form__message--error form__message--left-pad">
<Trans>{error}</Trans>
</span>
)}
</>
);
};
interface renderServiceFieldProps {
input: object;
placeholder?: string;
disabled?: boolean;
modifier?: string;
icon?: string;
meta: {
touched?: boolean;
error?: string;
};
}
export const renderServiceField = ({
input,
placeholder,
disabled,
modifier,
icon,
meta: { touched, error },
}: renderServiceFieldProps) => (
<>
<label className={cn('service custom-switch', { [modifier]: modifier })}>
<input
{...input}
type="checkbox"
className="custom-switch-input"
value={placeholder.toLowerCase()}
disabled={disabled}
/>
<span className="service__switch custom-switch-indicator"></span>
<span className="service__text" title={placeholder}>
{placeholder}
</span>
{icon && <div dangerouslySetInnerHTML={{ __html: window.atob(icon) }} className="service__icon" />}
</label>
{!disabled && touched && error && (
<span className="form__message form__message--error">
<Trans>{error}</Trans>
</span>
)}
</>
);
/** /**
* *
* @param {string} ip * @param {string} ip

View File

@@ -28,7 +28,7 @@ import {
THEMES, THEMES,
} from './constants'; } from './constants';
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from './localStorageHelper'; import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from './localStorageHelper';
import { DhcpInterface } from '../initialState'; import { DhcpInterface, InstallInterface } from '../initialState';
/** /**
* @param time {string} The time to format * @param time {string} The time to format
@@ -217,9 +217,9 @@ export const getInterfaceIp = (option: any) => {
return interfaceIP; return interfaceIP;
}; };
export const getIpList = (interfaces: DhcpInterface[]) => export const getIpList = (interfaces: InstallInterface[]) =>
Object.values(interfaces) Object.values(interfaces)
.reduce((acc: string[], curr: DhcpInterface) => acc.concat(curr.ip_addresses), [] as string[]) .reduce((acc: string[], curr: InstallInterface) => acc.concat(curr.ip_addresses), [] as string[])
.sort(); .sort();
/** /**
@@ -468,8 +468,6 @@ export const getParamsForClientsSearch = (data: any, param: any, additionalParam
* @param {function} [normalizeOnBlur] * @param {function} [normalizeOnBlur]
* @returns {function} * @returns {function}
*/ */
export const createOnBlurHandler = (event: any, input: any, normalizeOnBlur: any) =>
normalizeOnBlur ? input.onBlur(normalizeOnBlur(event.target.value)) : input.onBlur();
export const checkFiltered = (reason: any) => reason.indexOf(FILTERED) === 0; export const checkFiltered = (reason: any) => reason.indexOf(FILTERED) === 0;
export const checkRewrite = (reason: any) => reason === FILTERED_STATUS.REWRITE; export const checkRewrite = (reason: any) => reason === FILTERED_STATUS.REWRITE;

View File

@@ -24,7 +24,6 @@ import { ip4ToInt, isValidAbsolutePath } from './form';
import { isIpInCidr, parseSubnetMask } from './helpers'; import { isIpInCidr, parseSubnetMask } from './helpers';
// Validation functions // Validation functions
// https://redux-form.com/8.3.0/examples/fieldlevelvalidation/
// If the value is valid, the validation function should return undefined. // If the value is valid, the validation function should return undefined.
/** /**
* @param value {string|number} * @param value {string|number}
@@ -35,7 +34,7 @@ export const validateRequiredValue = (value: any) => {
if (formattedValue || formattedValue === 0 || (formattedValue && formattedValue.length !== 0)) { if (formattedValue || formattedValue === 0 || (formattedValue && formattedValue.length !== 0)) {
return undefined; return undefined;
} }
return 'form_error_required'; return i18next.t('form_error_required');
}; };
/** /**
@@ -51,7 +50,7 @@ export const validateIpv4RangeEnd = (_: any, allValues: any) => {
const { range_end, range_start } = allValues.v4; const { range_end, range_start } = allValues.v4;
if (ip4ToInt(range_end) <= ip4ToInt(range_start)) { if (ip4ToInt(range_end) <= ip4ToInt(range_start)) {
return 'greater_range_start_error'; return i18next.t('greater_range_start_error');
} }
return undefined; return undefined;
@@ -63,7 +62,7 @@ export const validateIpv4RangeEnd = (_: any, allValues: any) => {
*/ */
export const validateIpv4 = (value: any) => { export const validateIpv4 = (value: any) => {
if (value && !R_IPV4.test(value)) { if (value && !R_IPV4.test(value)) {
return 'form_error_ip4_format'; return i18next.t('form_error_ip4_format');
} }
return undefined; return undefined;
}; };
@@ -108,16 +107,16 @@ export const validateNotInRange = (value: any, allValues: any) => {
*/ */
export const validateGatewaySubnetMask = (_: any, allValues: any) => { export const validateGatewaySubnetMask = (_: any, allValues: any) => {
if (!allValues || !allValues.v4 || !allValues.v4.subnet_mask || !allValues.v4.gateway_ip) { if (!allValues || !allValues.v4 || !allValues.v4.subnet_mask || !allValues.v4.gateway_ip) {
return 'gateway_or_subnet_invalid'; return i18next.t('gateway_or_subnet_invalid');
} }
const { subnet_mask, gateway_ip } = allValues.v4; const { subnet_mask, gateway_ip } = allValues.v4;
if (validateIpv4(gateway_ip)) { if (validateIpv4(gateway_ip)) {
return 'gateway_or_subnet_invalid'; return i18next.t('gateway_or_subnet_invalid');
} }
return parseSubnetMask(subnet_mask) ? undefined : 'gateway_or_subnet_invalid'; return parseSubnetMask(subnet_mask) ? undefined : i18next.t('gateway_or_subnet_invalid');
}; };
/** /**
@@ -126,7 +125,7 @@ export const validateGatewaySubnetMask = (_: any, allValues: any) => {
* @param allValues * @param allValues
*/ */
export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => { export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => {
if (!allValues || !allValues.v4 || !value) { if (!allValues || !allValues.v4 || !value || !allValues.gateway_ip || !allValues.subnet_mask) {
return undefined; return undefined;
} }
@@ -139,7 +138,7 @@ export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => {
const subnetPrefix = parseSubnetMask(subnet_mask); const subnetPrefix = parseSubnetMask(subnet_mask);
if (!isIpInCidr(value, `${gateway_ip}/${subnetPrefix}`)) { if (!isIpInCidr(value, `${gateway_ip}/${subnetPrefix}`)) {
return 'subnet_error'; return i18next.t('subnet_error');
} }
return undefined; return undefined;
@@ -149,7 +148,7 @@ export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => {
* @param value {string} * @param value {string}
* @returns {undefined|string} * @returns {undefined|string}
*/ */
export const validateClientId = (value: any) => { export const validateClientId = (value: string) => {
if (!value) { if (!value) {
return undefined; return undefined;
} }
@@ -165,7 +164,7 @@ export const validateClientId = (value: any) => {
R_CLIENT_ID.test(formattedValue) R_CLIENT_ID.test(formattedValue)
) )
) { ) {
return 'form_error_client_id_format'; return i18next.t('form_error_client_id_format');
} }
return undefined; return undefined;
}; };
@@ -180,7 +179,7 @@ export const validateConfigClientId = (value: any) => {
} }
const formattedValue = value.trim(); const formattedValue = value.trim();
if (formattedValue && !R_CLIENT_ID.test(formattedValue)) { if (formattedValue && !R_CLIENT_ID.test(formattedValue)) {
return 'form_error_client_id_format'; return i18next.t('form_error_client_id_format');
} }
return undefined; return undefined;
}; };
@@ -195,7 +194,7 @@ export const validateServerName = (value: any) => {
} }
const formattedValue = value ? value.trim() : value; const formattedValue = value ? value.trim() : value;
if (formattedValue && !R_DOMAIN.test(formattedValue)) { if (formattedValue && !R_DOMAIN.test(formattedValue)) {
return 'form_error_server_name'; return i18next.t('form_error_server_name');
} }
return undefined; return undefined;
}; };
@@ -206,7 +205,7 @@ export const validateServerName = (value: any) => {
*/ */
export const validateIpv6 = (value: any) => { export const validateIpv6 = (value: any) => {
if (value && !R_IPV6.test(value)) { if (value && !R_IPV6.test(value)) {
return 'form_error_ip6_format'; return i18next.t('form_error_ip6_format');
} }
return undefined; return undefined;
}; };
@@ -217,7 +216,7 @@ export const validateIpv6 = (value: any) => {
*/ */
export const validateIp = (value: any) => { export const validateIp = (value: any) => {
if (value && !R_IPV4.test(value) && !R_IPV6.test(value)) { if (value && !R_IPV4.test(value) && !R_IPV6.test(value)) {
return 'form_error_ip_format'; return i18next.t('form_error_ip_format');
} }
return undefined; return undefined;
}; };
@@ -228,7 +227,7 @@ export const validateIp = (value: any) => {
*/ */
export const validateMac = (value: any) => { export const validateMac = (value: any) => {
if (value && !R_MAC.test(value)) { if (value && !R_MAC.test(value)) {
return 'form_error_mac_format'; return i18next.t('form_error_mac_format');
} }
return undefined; return undefined;
}; };
@@ -239,7 +238,7 @@ export const validateMac = (value: any) => {
*/ */
export const validatePort = (value: any) => { export const validatePort = (value: any) => {
if ((value || value === 0) && (value < STANDARD_WEB_PORT || value > MAX_PORT)) { if ((value || value === 0) && (value < STANDARD_WEB_PORT || value > MAX_PORT)) {
return 'form_error_port_range'; return i18next.t('form_error_port_range');
} }
return undefined; return undefined;
}; };
@@ -250,7 +249,7 @@ export const validatePort = (value: any) => {
*/ */
export const validateInstallPort = (value: any) => { export const validateInstallPort = (value: any) => {
if (value < 1 || value > MAX_PORT) { if (value < 1 || value > MAX_PORT) {
return 'form_error_port'; return i18next.t('form_error_port');
} }
return undefined; return undefined;
}; };
@@ -264,7 +263,7 @@ export const validatePortTLS = (value: any) => {
return undefined; return undefined;
} }
if (value && (value < STANDARD_WEB_PORT || value > MAX_PORT)) { if (value && (value < STANDARD_WEB_PORT || value > MAX_PORT)) {
return 'form_error_port_range'; return i18next.t('form_error_port_range');
} }
return undefined; return undefined;
}; };
@@ -281,7 +280,7 @@ export const validatePortQuic = validatePortTLS;
*/ */
export const validateIsSafePort = (value: any) => { export const validateIsSafePort = (value: any) => {
if (UNSAFE_PORTS.includes(value)) { if (UNSAFE_PORTS.includes(value)) {
return 'form_error_port_unsafe'; return i18next.t('form_error_port_unsafe');
} }
return undefined; return undefined;
}; };
@@ -292,7 +291,7 @@ export const validateIsSafePort = (value: any) => {
*/ */
export const validateDomain = (value: any) => { export const validateDomain = (value: any) => {
if (value && !R_HOST.test(value)) { if (value && !R_HOST.test(value)) {
return 'form_error_domain_format'; return i18next.t('form_error_domain_format');
} }
return undefined; return undefined;
}; };
@@ -303,7 +302,7 @@ export const validateDomain = (value: any) => {
*/ */
export const validateAnswer = (value: any) => { export const validateAnswer = (value: any) => {
if (value && !R_IPV4.test(value) && !R_IPV6.test(value) && !R_HOST.test(value)) { if (value && !R_IPV4.test(value) && !R_IPV6.test(value) && !R_HOST.test(value)) {
return 'form_error_answer_format'; return i18next.t('form_error_answer_format');
} }
return undefined; return undefined;
}; };
@@ -314,7 +313,7 @@ export const validateAnswer = (value: any) => {
*/ */
export const validatePath = (value: any) => { export const validatePath = (value: any) => {
if (value && !isValidAbsolutePath(value) && !R_URL_REQUIRES_PROTOCOL.test(value)) { if (value && !isValidAbsolutePath(value) && !R_URL_REQUIRES_PROTOCOL.test(value)) {
return 'form_error_url_or_path_format'; return i18next.t('form_error_url_or_path_format');
} }
return undefined; return undefined;
}; };
@@ -402,7 +401,7 @@ export const validatePlainDns = (value: any, allValues: any) => {
const { enabled } = allValues; const { enabled } = allValues;
if (!enabled && !value) { if (!enabled && !value) {
return 'encryption_plain_dns_error'; return i18next.t('encryption_plain_dns_error');
} }
return undefined; return undefined;

View File

@@ -1,9 +1,7 @@
import { import {
ALL_INTERFACES_IP,
BLOCKING_MODES, BLOCKING_MODES,
DAY, DAY,
DEFAULT_LOGS_FILTER, DEFAULT_LOGS_FILTER,
INSTALL_FIRST_STEP,
STANDARD_DNS_PORT, STANDARD_DNS_PORT,
STANDARD_WEB_PORT, STANDARD_WEB_PORT,
TIME_UNITS, TIME_UNITS,
@@ -11,6 +9,14 @@ import {
import { DEFAULT_BLOCKING_IPV4, DEFAULT_BLOCKING_IPV6 } from './reducers/dnsConfig'; import { DEFAULT_BLOCKING_IPV4, DEFAULT_BLOCKING_IPV6 } from './reducers/dnsConfig';
import { Filter } from './helpers/helpers'; import { Filter } from './helpers/helpers';
export type InstallInterface = {
flags: string;
hardware_address: string;
ip_addresses: string[];
mtu: number;
name: string;
};
export type InstallData = { export type InstallData = {
step: number; step: number;
processingDefault: boolean; processingDefault: boolean;
@@ -33,13 +39,7 @@ export type InstallData = {
ip: string; ip: string;
error: string; error: string;
}; };
interfaces: { interfaces: InstallInterface[];
flags: string;
hardware_address: string;
ip_addresses: string[];
mtu: number;
name: string;
}[];
dnsVersion: string; dnsVersion: string;
}; };
@@ -78,17 +78,17 @@ export type EncryptionData = {
}; };
export type Client = { export type Client = {
blocked_services: string[], blocked_services: string[];
blocked_services_schedule: { blocked_services_schedule: {
sun?: { start: number, end: number }, sun?: { start: number; end: number };
mon?: { start: number, end: number }, mon?: { start: number; end: number };
tue?: { start: number, end: number }, tue?: { start: number; end: number };
wed?: { start: number, end: number }, wed?: { start: number; end: number };
thu?: { start: number, end: number }, thu?: { start: number; end: number };
fri?: { start: number, end: number }, fri?: { start: number; end: number };
sat?: { start: number, end: number }, sat?: { start: number; end: number };
time_zone: string; time_zone: string;
}, };
filtering_enabled: boolean; filtering_enabled: boolean;
ids: string[]; ids: string[];
ignore_querylog: boolean; ignore_querylog: boolean;
@@ -104,14 +104,14 @@ export type Client = {
upstreams_cache_size: number; upstreams_cache_size: number;
use_global_blocked_services: boolean; use_global_blocked_services: boolean;
use_global_settings: boolean; use_global_settings: boolean;
} };
export type AutoClient = { export type AutoClient = {
ip: string; ip: string;
name: string; name: string;
source: string; source: string;
whois_info: any; whois_info: any;
} };
export type DashboardData = { export type DashboardData = {
processing: boolean; processing: boolean;
@@ -151,13 +151,13 @@ export type SettingsData = {
order: number; order: number;
subtitle: string; subtitle: string;
title: string; title: string;
}, };
safebrowsing: { safebrowsing: {
enabled: boolean; enabled: boolean;
order: number; order: number;
subtitle: string; subtitle: string;
title: string; title: string;
}, };
safesearch: Record<string, boolean>; safesearch: Record<string, boolean>;
}; };
}; };
@@ -182,7 +182,7 @@ export type RewritesData = {
export type NormalizedTopClients = { export type NormalizedTopClients = {
auto: Record<string, number>; auto: Record<string, number>;
configured: Record<string, number>; configured: Record<string, number>;
} };
export type StatsData = { export type StatsData = {
processingGetConfig: boolean; processingGetConfig: boolean;
@@ -256,13 +256,13 @@ export type DhcpData = {
interface_name: string; interface_name: string;
check?: { check?: {
v4?: { v4?: {
other_server?: { found: string; error?: string }, other_server?: { found: string; error?: string };
static_ip?: {static: string, ip: string}, static_ip?: { static: string; ip: string };
}, };
v6?: { v6?: {
other_server?: { found: string; error?: string }, other_server?: { found: string; error?: string };
static_ip?: {static: string, ip: string}, static_ip?: { static: string; ip: string };
}, };
}; };
v4: { v4: {
gateway_ip: string; gateway_ip: string;
@@ -321,7 +321,7 @@ export type DnsConfigData = {
ratelimit_subnet_len_ipv4?: number; ratelimit_subnet_len_ipv4?: number;
ratelimit_subnet_len_ipv6?: number; ratelimit_subnet_len_ipv6?: number;
edns_cs_use_custom?: boolean; edns_cs_use_custom?: boolean;
edns_cs_custom_ip?: boolean; edns_cs_custom_ip?: string;
cache_size?: number; cache_size?: number;
cache_ttl_max?: number; cache_ttl_max?: number;
cache_ttl_min?: number; cache_ttl_min?: number;
@@ -391,7 +391,6 @@ export type RootState = {
install?: InstallData; install?: InstallData;
toasts: { notices: any[] }; toasts: { notices: any[] };
loadingBar: any; loadingBar: any;
form: any;
}; };
export type InstallState = { export type InstallState = {
@@ -617,5 +616,4 @@ export const initialState: RootState = {
}, },
toasts: { notices: [] }, toasts: { notices: [] },
loadingBar: {}, loadingBar: {},
form: {},
}; };

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { getIpList, getDnsAddress, getWebAddress } from '../../helpers/helpers'; import { getIpList, getDnsAddress, getWebAddress } from '../../helpers/helpers';
import { ALL_INTERFACES_IP } from '../../helpers/constants'; import { ALL_INTERFACES_IP } from '../../helpers/constants';
import { DhcpInterface } from '../../initialState'; import { InstallInterface } from '../../initialState';
interface renderItemProps { interface renderItemProps {
ip: string; ip: string;
@@ -28,7 +28,7 @@ const renderItem = ({ ip, port, isDns }: renderItemProps) => {
}; };
interface AddressListProps { interface AddressListProps {
interfaces: DhcpInterface[]; interfaces: InstallInterface[];
address: string; address: string;
port: number; port: number;
isDns?: boolean; isDns?: boolean;

View File

@@ -1,47 +1,47 @@
import React from 'react'; import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Field, reduxForm } from 'redux-form'; import { Trans, useTranslation } from 'react-i18next';
import { withTranslation, Trans } from 'react-i18next';
import flow from 'lodash/flow';
import i18n from '../../i18n';
import Controls from './Controls'; import Controls from './Controls';
import { validatePasswordLength, validateRequiredValue } from '../../helpers/validators';
import { Input } from '../../components/ui/Controls/Input';
import { renderInputField } from '../../helpers/form'; type AuthFormValues = {
import { FORM_NAME } from '../../helpers/constants'; username: string;
import { validatePasswordLength } from '../../helpers/validators'; password: string;
confirm_password: string;
const required = (value: any) => {
if (value || value === 0) {
return false;
}
return <Trans>form_error_required</Trans>;
}; };
const validate = (values: any) => { type Props = {
const errors: { confirm_password?: string } = {}; onAuthSubmit: (values: AuthFormValues) => void;
if (values.confirm_password !== values.password) {
errors.confirm_password = i18n.t('form_error_password');
}
return errors;
}; };
interface AuthProps { export const Auth = ({ onAuthSubmit }: Props) => {
handleSubmit: (...args: unknown[]) => string; const { t } = useTranslation();
pristine: boolean; const {
invalid: boolean; handleSubmit,
t: (...args: unknown[]) => string; watch,
} control,
formState: { isDirty, isValid },
} = useForm<AuthFormValues>({
mode: 'onBlur',
defaultValues: {
username: '',
password: '',
confirm_password: '',
},
});
const Auth = (props: AuthProps) => { const password = watch('password');
const { handleSubmit, pristine, invalid, t } = props;
const validateConfirmPassword = (value: string) => {
if (value !== password) {
return t('form_error_password');
}
return undefined;
};
return ( return (
<form className="setup__step" onSubmit={handleSubmit}> <form className="setup__step" onSubmit={handleSubmit(onAuthSubmit)}>
<div className="setup__group"> <div className="setup__group">
<div className="setup__subtitle"> <div className="setup__subtitle">
<Trans>install_auth_title</Trans> <Trans>install_auth_title</Trans>
@@ -52,65 +52,74 @@ const Auth = (props: AuthProps) => {
</p> </p>
<div className="form-group"> <div className="form-group">
<label> <Controller
<Trans>install_auth_username</Trans>
</label>
<Field
name="username" name="username"
component={renderInputField} control={control}
type="text" rules={{ validate: validateRequiredValue }}
className="form-control" render={({ field, fieldState }) => (
placeholder={t('install_auth_username_enter')} <Input
validate={[required]} {...field}
autoComplete="username" type="text"
data-testid="install_username"
label={t('install_auth_username')}
placeholder={t('install_auth_username_enter')}
error={fieldState.error?.message}
autoComplete="username"
/>
)}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label> <Controller
<Trans>install_auth_password</Trans>
</label>
<Field
name="password" name="password"
component={renderInputField} control={control}
type="password" rules={{
className="form-control" validate: {
placeholder={t('install_auth_password_enter')} required: validateRequiredValue,
validate={[required, validatePasswordLength]} passwordLength: validatePasswordLength,
autoComplete="new-password" },
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="password"
data-testid="install_password"
label={t('install_auth_password')}
placeholder={t('install_auth_password_enter')}
error={fieldState.error?.message}
autoComplete="new-password"
/>
)}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label> <Controller
<Trans>install_auth_confirm</Trans>
</label>
<Field
name="confirm_password" name="confirm_password"
component={renderInputField} control={control}
type="password" rules={{
className="form-control" validate: {
placeholder={t('install_auth_confirm')} required: validateRequiredValue,
validate={[required]} confirmPassword: validateConfirmPassword,
autoComplete="new-password" },
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="password"
data-testid="install_confirm_password"
label={t('install_auth_confirm')}
placeholder={t('install_auth_confirm')}
error={fieldState.error?.message}
autoComplete="new-password"
/>
)}
/> />
</div> </div>
</div> </div>
<Controls pristine={pristine} invalid={invalid} /> <Controls isDirty={isDirty} isValid={isValid} />
</form> </form>
); );
}; };
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.INSTALL,
destroyOnUnmount: false,
forceUnregisterOnUnmount: true,
validate,
}),
])(Auth);

View File

@@ -32,6 +32,7 @@ class Controls extends Component<ControlsProps> {
case 3: case 3:
return ( return (
<button <button
data-testid="install_back"
type="button" type="button"
className="btn btn-secondary btn-lg setup__button" className="btn btn-secondary btn-lg setup__button"
onClick={this.props.prevStep}> onClick={this.props.prevStep}>
@@ -44,24 +45,16 @@ class Controls extends Component<ControlsProps> {
} }
renderNextButton(step: any) { renderNextButton(step: any) {
const { const { nextStep, invalid, pristine, install, ip, port } = this.props;
nextStep,
invalid,
pristine,
install,
ip,
port,
} = this.props;
switch (step) { switch (step) {
case 1: case 1:
return ( return (
<button type="button" className="btn btn-success btn-lg setup__button" onClick={nextStep}> <button
data-testid="install_get_started"
type="button"
className="btn btn-success btn-lg setup__button"
onClick={nextStep}>
<Trans>get_started</Trans> <Trans>get_started</Trans>
</button> </button>
); );
@@ -69,6 +62,7 @@ class Controls extends Component<ControlsProps> {
case 3: case 3:
return ( return (
<button <button
data-testid="install_next"
type="submit" type="submit"
className="btn btn-success btn-lg setup__button" className="btn btn-success btn-lg setup__button"
disabled={invalid || pristine || install.processingSubmit}> disabled={invalid || pristine || install.processingSubmit}>
@@ -77,13 +71,18 @@ class Controls extends Component<ControlsProps> {
); );
case 4: case 4:
return ( return (
<button type="button" className="btn btn-success btn-lg setup__button" onClick={nextStep}> <button
data-testid="install_next"
type="button"
className="btn btn-success btn-lg setup__button"
onClick={nextStep}>
<Trans>next</Trans> <Trans>next</Trans>
</button> </button>
); );
case 5: case 5:
return ( return (
<button <button
data-testid="install_open_dashboard"
type="button" type="button"
className="btn btn-success btn-lg setup__button" className="btn btn-success btn-lg setup__button"
onClick={() => this.props.openDashboard(ip, port)}> onClick={() => this.props.openDashboard(ip, port)}>

View File

@@ -1,25 +1,21 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { reduxForm, formValueSelector } from 'redux-form'; import { Trans } from 'react-i18next';
import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import Guide from '../../components/ui/Guide'; import { Guide } from '../../components/ui/Guide';
import Controls from './Controls'; import Controls from './Controls';
import AddressList from './AddressList'; import AddressList from './AddressList';
import { FORM_NAME } from '../../helpers/constants'; import { InstallInterface } from '../../initialState';
import { DhcpInterface } from '../../initialState'; import { DnsConfig } from './Settings';
interface DevicesProps { type Props = {
interfaces: DhcpInterface[]; interfaces: InstallInterface[];
dnsIp: string; dnsConfig: DnsConfig;
dnsPort: number; };
}
let Devices = (props: DevicesProps) => ( export const Devices = ({ interfaces, dnsConfig }: Props) => (
<div className="setup__step"> <div className="setup__step">
<div className="setup__group"> <div className="setup__group">
<div className="setup__subtitle"> <div className="setup__subtitle">
@@ -34,7 +30,7 @@ let Devices = (props: DevicesProps) => (
</div> </div>
<div className="mt-1"> <div className="mt-1">
<AddressList interfaces={props.interfaces} address={props.dnsIp} port={props.dnsPort} isDns /> <AddressList interfaces={interfaces} address={dnsConfig.ip} port={dnsConfig.port} isDns />
</div> </div>
</div> </div>
@@ -44,24 +40,3 @@ let Devices = (props: DevicesProps) => (
<Controls /> <Controls />
</div> </div>
); );
const selector = formValueSelector('install');
Devices = connect((state) => {
const dnsIp = selector(state, 'dns.ip');
const dnsPort = selector(state, 'dns.port');
return {
dnsIp,
dnsPort,
};
})(Devices);
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.INSTALL,
destroyOnUnmount: false,
forceUnregisterOnUnmount: true,
}),
])(Devices);

View File

@@ -1,21 +1,19 @@
import React from 'react'; import React from 'react';
import { Trans, withTranslation } from 'react-i18next'; import { Trans } from 'react-i18next';
import { INSTALL_TOTAL_STEPS } from '../../helpers/constants'; import { INSTALL_TOTAL_STEPS } from '../../helpers/constants';
const getProgressPercent = (step: any) => (step / INSTALL_TOTAL_STEPS) * 100; const getProgressPercent = (step: number) => (step / INSTALL_TOTAL_STEPS) * 100;
type Props = { type Props = {
step: number; step: number;
}; };
const Progress = (props: Props) => ( export const Progress = ({ step }: Props) => (
<div className="setup__progress"> <div className="setup__progress">
<Trans>install_step</Trans> {props.step}/{INSTALL_TOTAL_STEPS} <Trans>install_step</Trans> {step}/{INSTALL_TOTAL_STEPS}
<div className="setup__progress-wrap"> <div className="setup__progress-wrap">
<div className="setup__progress-inner" style={{ width: `${getProgressPercent(props.step)}%` }} /> <div className="setup__progress-inner" style={{ width: `${getProgressPercent(step)}%` }} />
</div> </div>
</div> </div>
); );
export default withTranslation()(Progress);

View File

@@ -1,32 +1,84 @@
import React, { Component } from 'react'; import React, { useEffect, useCallback } from 'react';
import { connect } from 'react-redux'; import { useForm, Controller } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { Field, reduxForm, formValueSelector } from 'redux-form'; import i18n from 'i18next';
import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import i18n, { TFunction } from 'i18next';
import Controls from './Controls'; import Controls from './Controls';
import AddressList from './AddressList'; import AddressList from './AddressList';
import { getInterfaceIp } from '../../helpers/helpers'; import { getInterfaceIp } from '../../helpers/helpers';
import { import {
ALL_INTERFACES_IP, ALL_INTERFACES_IP,
FORM_NAME,
ADDRESS_IN_USE_TEXT, ADDRESS_IN_USE_TEXT,
PORT_53_FAQ_LINK, PORT_53_FAQ_LINK,
STATUS_RESPONSE, STATUS_RESPONSE,
STANDARD_DNS_PORT, STANDARD_DNS_PORT,
STANDARD_WEB_PORT, STANDARD_WEB_PORT,
MAX_PORT,
MIN_PORT,
} from '../../helpers/constants'; } from '../../helpers/constants';
import { renderInputField, toNumber } from '../../helpers/form'; import { validateRequiredValue } from '../../helpers/validators';
import { validateRequiredValue, validateInstallPort } from '../../helpers/validators'; import { InstallInterface } from '../../initialState';
import { DhcpInterface } from '../../initialState'; import { Input } from '../../components/ui/Controls/Input';
import { Select } from '../../components/ui/Controls/Select';
import { toNumber } from '../../helpers/form';
const renderInterfaces = (interfaces: DhcpInterface[]) => const validateInstallPort = (value: number) => {
Object.values(interfaces).map((option: DhcpInterface) => { if (value < MIN_PORT || value > MAX_PORT) {
return i18n.t('form_error_port');
}
return undefined;
};
export type WebConfig = {
ip: string;
port: number;
};
export type DnsConfig = {
ip: string;
port: number;
};
export type SettingsFormValues = {
web: WebConfig;
dns: DnsConfig;
};
type StaticIpType = {
ip: string;
static: string;
};
export type ConfigType = {
web: {
ip: string;
port?: number;
status: string;
can_autofix: boolean;
};
dns: {
ip: string;
port?: number;
status: string;
can_autofix: boolean;
};
staticIp: StaticIpType;
};
type Props = {
handleSubmit: (data: SettingsFormValues) => void;
handleChange?: (data: SettingsFormValues) => unknown;
handleFix: (web: WebConfig, dns: DnsConfig, set_static_ip: boolean) => void;
validateForm: (data: SettingsFormValues) => void;
config: ConfigType;
interfaces: InstallInterface[];
initialValues?: object;
};
const renderInterfaces = (interfaces: InstallInterface[]) =>
Object.values(interfaces).map((option: InstallInterface) => {
const { name, ip_addresses, flags } = option; const { name, ip_addresses, flags } = option;
if (option && ip_addresses?.length > 0) { if (option && ip_addresses?.length > 0) {
@@ -43,113 +95,70 @@ const renderInterfaces = (interfaces: DhcpInterface[]) =>
return null; return null;
}); });
type Props = { export const Settings = ({ handleSubmit, handleFix, validateForm, config, interfaces }: Props) => {
handleSubmit: (...args: unknown[]) => string; const { t } = useTranslation();
handleChange?: (...args: unknown[]) => unknown;
handleFix: (...args: unknown[]) => unknown; const defaultValues = {
validateForm?: (...args: unknown[]) => unknown;
webIp: string;
dnsIp: string;
config: {
web: { web: {
status: string; ip: config.web.ip || ALL_INTERFACES_IP,
can_autofix: boolean; port: config.web.port || STANDARD_WEB_PORT,
}; },
dns: { dns: {
status: string; ip: config.dns.ip || ALL_INTERFACES_IP,
can_autofix: boolean; port: config.dns.port || STANDARD_DNS_PORT,
}; },
staticIp: {
ip: string;
static: string;
};
}; };
webPort?: number;
dnsPort?: number;
interfaces: DhcpInterface[];
invalid: boolean;
initialValues?: object;
t: TFunction;
};
class Settings extends Component<Props> { const {
componentDidMount() { control,
const { webIp, webPort, dnsIp, dnsPort } = this.props; watch,
handleSubmit: reactHookFormSubmit,
formState: { isValid },
} = useForm<SettingsFormValues>({
defaultValues,
mode: 'onBlur',
});
this.props.validateForm({ const watchFields = watch();
const { status: webStatus, can_autofix: isWebFixAvailable } = config.web;
const { status: dnsStatus, can_autofix: isDnsFixAvailable } = config.dns;
const { staticIp } = config;
const webIpVal = watch('web.ip');
const webPortVal = watch('web.port');
const dnsIpVal = watch('dns.ip');
const dnsPortVal = watch('dns.port');
useEffect(() => {
const webPortError = validateInstallPort(webPortVal);
const dnsPortError = validateInstallPort(dnsPortVal);
if (webPortError || dnsPortError) {
return;
}
validateForm({
web: { web: {
ip: webIp, ip: webIpVal,
port: webPort, port: webPortVal,
}, },
dns: { dns: {
ip: dnsIp, ip: dnsIpVal,
port: dnsPort, port: dnsPortVal,
}, },
}); });
} }, [webIpVal, webPortVal, dnsIpVal, dnsPortVal]);
getStaticIpMessage = (staticIp: { ip: string; static: string }) => {
const { static: status, ip } = staticIp;
switch (status) {
case STATUS_RESPONSE.NO: {
return (
<>
<div className="mb-2">
<Trans values={{ ip }} components={[<strong key="0">text</strong>]}>
install_static_configure
</Trans>
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => this.handleStaticIp(ip)}>
<Trans>set_static_ip</Trans>
</button>
</>
);
}
case STATUS_RESPONSE.ERROR: {
return (
<div className="text-danger">
<Trans>install_static_error</Trans>
</div>
);
}
case STATUS_RESPONSE.YES: {
return (
<div className="text-success">
<Trans>install_static_ok</Trans>
</div>
);
}
default:
return null;
}
};
handleAutofix = (type: any) => {
const {
webIp,
webPort,
dnsIp,
dnsPort,
handleFix,
} = this.props;
const handleAutofix = (type: string) => {
const web = { const web = {
ip: webIp, ip: watchFields.web?.ip,
port: webPort, port: watchFields.web?.port,
autofix: false, autofix: false,
}; };
const dns = { const dns = {
ip: dnsIp, ip: watchFields.dns?.ip,
port: dnsPort, port: watchFields.dns?.port,
autofix: false, autofix: false,
}; };
const set_static_ip = false; const set_static_ip = false;
@@ -163,276 +172,292 @@ class Settings extends Component<Props> {
handleFix(web, dns, set_static_ip); handleFix(web, dns, set_static_ip);
}; };
handleStaticIp = (ip: any) => { const handleStaticIp = (ip: string) => {
const {
webIp,
webPort,
dnsIp,
dnsPort,
handleFix,
} = this.props;
const web = { const web = {
ip: webIp, ip: watchFields.web?.ip,
port: webPort, port: watchFields.web?.port,
autofix: false, autofix: false,
}; };
const dns = { const dns = {
ip: dnsIp, ip: watchFields.dns?.ip,
port: dnsPort, port: watchFields.dns?.port,
autofix: false, autofix: false,
}; };
const set_static_ip = true; const set_static_ip = true;
if (window.confirm(this.props.t('confirm_static_ip', { ip }))) { if (window.confirm(t('confirm_static_ip', { ip }))) {
handleFix(web, dns, set_static_ip); handleFix(web, dns, set_static_ip);
} }
}; };
render() { const getStaticIpMessage = useCallback(
const { (staticIp: StaticIpType) => {
handleSubmit, const { static: status, ip } = staticIp;
handleChange, switch (status) {
case STATUS_RESPONSE.NO:
return (
<>
<div className="mb-2">
<Trans values={{ ip }} components={[<strong key="0">text</strong>]}>
install_static_configure
</Trans>
</div>
webIp, <button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => handleStaticIp(ip)}>
<Trans>set_static_ip</Trans>
</button>
</>
);
case STATUS_RESPONSE.ERROR:
return (
<div className="text-danger">
<Trans>install_static_error</Trans>
</div>
);
case STATUS_RESPONSE.YES:
return (
<div className="text-success">
<Trans>install_static_ok</Trans>
</div>
);
default:
return null;
}
},
[handleStaticIp],
);
webPort, const onSubmit = (data: SettingsFormValues) => {
validateForm(data);
handleSubmit(data);
};
dnsIp, return (
<form className="setup__step" onSubmit={reactHookFormSubmit(onSubmit)}>
<div className="setup__group">
<div className="setup__subtitle">
<Trans>install_settings_title</Trans>
</div>
dnsPort, <div className="row">
<div className="col-8">
interfaces, <div className="form-group">
<label>
invalid, <Trans>install_settings_listen</Trans>
</label>
config, <Controller
name="web.ip"
t, control={control}
} = this.props; render={({ field }) => (
const { status: webStatus, can_autofix: isWebFixAvailable } = config.web; <Select {...field} data-testid="install_web_ip">
const { status: dnsStatus, can_autofix: isDnsFixAvailable } = config.dns; <option value={ALL_INTERFACES_IP}>
const { staticIp } = config; {t('install_settings_all_interfaces')}
</option>
return ( {renderInterfaces(interfaces)}
<form className="setup__step" onSubmit={handleSubmit}> </Select>
<div className="setup__group"> )}
<div className="setup__subtitle"> />
<Trans>install_settings_title</Trans> </div>
</div> </div>
<div className="row"> <div className="col-4">
<div className="col-8"> <div className="form-group">
<div className="form-group"> <label>
<label> <Trans>install_settings_port</Trans>
<Trans>install_settings_listen</Trans> </label>
</label> <Controller
name="web.port"
<Field control={control}
name="web.ip" rules={{
component="select" validate: {
className="form-control custom-select" required: validateRequiredValue,
onChange={handleChange}> installPort: validateInstallPort,
<option value={ALL_INTERFACES_IP}> },
{this.props.t('install_settings_all_interfaces')} }}
</option> render={({ field, fieldState }) => (
{renderInterfaces(interfaces)} <Input
</Field> {...field}
</div> type="number"
data-testid="install_web_port"
placeholder={STANDARD_WEB_PORT.toString()}
error={fieldState.error?.message}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/>
</div> </div>
</div>
<div className="col-4"> <div className="col-12">
<div className="form-group"> {webStatus && (
<label> <div className="setup__error text-danger">
<Trans>install_settings_port</Trans> {webStatus}
</label> {isWebFixAvailable && (
<button
<Field type="button"
name="web.port" data-testid="install_web_fix"
component={renderInputField} className="btn btn-secondary btn-sm ml-2"
type="number" onClick={() => handleAutofix('web')}>
className="form-control" <Trans>fix</Trans>
placeholder={STANDARD_WEB_PORT.toString()} </button>
validate={[validateInstallPort, validateRequiredValue]} )}
normalize={toNumber}
onChange={handleChange}
/>
</div> </div>
</div> )}
<div className="col-12"> <hr className="divider--small" />
{webStatus && ( </div>
</div>
<div className="setup__desc">
<Trans>install_settings_interface_link</Trans>
<div className="mt-1">
<AddressList
interfaces={interfaces}
address={watchFields.web?.ip}
port={watchFields.web?.port}
/>
</div>
</div>
</div>
<div className="setup__group">
<div className="setup__subtitle">
<Trans>install_settings_dns</Trans>
</div>
<div className="row">
<div className="col-8">
<div className="form-group">
<label>
<Trans>install_settings_listen</Trans>
</label>
<Controller
name="dns.ip"
control={control}
render={({ field }) => (
<Select {...field} data-testid="install_dns_ip">
<option value={ALL_INTERFACES_IP}>
{t('install_settings_all_interfaces')}
</option>
{renderInterfaces(interfaces)}
</Select>
)}
/>
</div>
</div>
<div className="col-4">
<div className="form-group">
<label>
<Trans>install_settings_port</Trans>
</label>
<Controller
name="dns.port"
control={control}
rules={{
required: t('form_error_required'),
validate: {
required: validateRequiredValue,
installPort: validateInstallPort,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
data-testid="install_dns_port"
error={fieldState.error?.message}
placeholder={STANDARD_WEB_PORT.toString()}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/>
</div>
</div>
<div className="col-12">
{dnsStatus && (
<>
<div className="setup__error text-danger"> <div className="setup__error text-danger">
{webStatus} {dnsStatus}
{isWebFixAvailable && ( {isDnsFixAvailable && (
<button <button
type="button" type="button"
data-testid="install_dns_fix"
className="btn btn-secondary btn-sm ml-2" className="btn btn-secondary btn-sm ml-2"
onClick={() => this.handleAutofix('web')}> onClick={() => handleAutofix('dns')}>
<Trans>fix</Trans> <Trans>fix</Trans>
</button> </button>
)} )}
</div> </div>
)} {isDnsFixAvailable && (
<div className="text-muted mb-2">
<hr className="divider--small" /> <p className="mb-1">
</div> <Trans>autofix_warning_text</Trans>
</div> </p>
<Trans components={[<li key="0">text</li>]}>autofix_warning_list</Trans>
<div className="setup__desc"> <p className="mb-1">
<Trans>install_settings_interface_link</Trans> <Trans>autofix_warning_result</Trans>
</p>
<div className="mt-1">
<AddressList interfaces={interfaces} address={webIp} port={webPort} />
</div>
</div>
</div>
<div className="setup__group">
<div className="setup__subtitle">
<Trans>install_settings_dns</Trans>
</div>
<div className="row">
<div className="col-8">
<div className="form-group">
<label>
<Trans>install_settings_listen</Trans>
</label>
<Field
name="dns.ip"
component="select"
className="form-control custom-select"
onChange={handleChange}>
<option value={ALL_INTERFACES_IP}>{t('install_settings_all_interfaces')}</option>
{renderInterfaces(interfaces)}
</Field>
</div>
</div>
<div className="col-4">
<div className="form-group">
<label>
<Trans>install_settings_port</Trans>
</label>
<Field
name="dns.port"
component={renderInputField}
type="number"
className="form-control"
placeholder={STANDARD_WEB_PORT.toString()}
validate={[validateInstallPort, validateRequiredValue]}
normalize={toNumber}
onChange={handleChange}
/>
</div>
</div>
<div className="col-12">
{dnsStatus && (
<>
<div className="setup__error text-danger">
{dnsStatus}
{isDnsFixAvailable && (
<button
type="button"
className="btn btn-secondary btn-sm ml-2"
onClick={() => this.handleAutofix('dns')}>
<Trans>fix</Trans>
</button>
)}
</div> </div>
{isDnsFixAvailable && (
<div className="text-muted mb-2">
<p className="mb-1">
<Trans>autofix_warning_text</Trans>
</p>
<Trans components={[<li key="0">text</li>]}>autofix_warning_list</Trans>
<p className="mb-1">
<Trans>autofix_warning_result</Trans>
</p>
</div>
)}
</>
)}
{dnsPort === STANDARD_DNS_PORT &&
!isDnsFixAvailable &&
dnsStatus.includes(ADDRESS_IN_USE_TEXT) && (
<Trans
components={[
<a
href={PORT_53_FAQ_LINK}
key="0"
target="_blank"
rel="noopener noreferrer">
link
</a>,
]}>
port_53_faq_link
</Trans>
)} )}
</>
)}
{watchFields.dns?.port === STANDARD_DNS_PORT &&
!isDnsFixAvailable &&
dnsStatus?.includes(ADDRESS_IN_USE_TEXT) && (
<Trans
components={[
<a href={PORT_53_FAQ_LINK} key="0" target="_blank" rel="noopener noreferrer">
link
</a>,
]}>
port_53_faq_link
</Trans>
)}
<hr className="divider--small" /> <hr className="divider--small" />
</div>
</div>
<div className="setup__desc">
<Trans>install_settings_dns_desc</Trans>
<div className="mt-1">
<AddressList interfaces={interfaces} address={dnsIp} port={dnsPort} isDns={true} />
</div>
</div> </div>
</div> </div>
<div className="setup__group"> <div className="setup__desc">
<div className="setup__subtitle"> <Trans>install_settings_dns_desc</Trans>
<Trans>static_ip</Trans>
</div>
<div className="mb-2"> <div className="mt-1">
<Trans>static_ip_desc</Trans> <AddressList
interfaces={interfaces}
address={watchFields.dns?.ip}
port={watchFields.dns?.port}
isDns={true}
/>
</div> </div>
</div>
</div>
{this.getStaticIpMessage(staticIp)} <div className="setup__group">
<div className="setup__subtitle">
<Trans>static_ip</Trans>
</div> </div>
<Controls invalid={invalid} /> <div className="mb-2">
</form> <Trans>static_ip_desc</Trans>
); </div>
}
}
const selector = formValueSelector(FORM_NAME.INSTALL); {getStaticIpMessage(staticIp)}
</div>
const SettingsForm = connect((state) => { <Controls invalid={!isValid} />
const webIp = selector(state, 'web.ip'); </form>
const webPort = selector(state, 'web.port'); );
const dnsIp = selector(state, 'dns.ip'); };
const dnsPort = selector(state, 'dns.port');
return {
webIp,
webPort,
dnsIp,
dnsPort,
};
})(Settings);
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.INSTALL,
destroyOnUnmount: false,
forceUnregisterOnUnmount: true,
}),
])(SettingsForm);

View File

@@ -1,23 +1,16 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { reduxForm, formValueSelector } from 'redux-form'; import { Trans } from 'react-i18next';
import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import Controls from './Controls'; import Controls from './Controls';
import { FORM_NAME } from '../../helpers/constants'; import { WebConfig } from './Settings';
interface SubmitProps { type Props = {
webIp: string; webConfig: WebConfig;
webPort: number; openDashboard: (ip: string, port: number) => void;
handleSubmit: (...args: unknown[]) => string; };
pristine: boolean;
submitting: boolean;
openDashboard: (...args: unknown[]) => unknown;
}
let Submit = (props: SubmitProps) => ( export const Submit = ({ openDashboard, webConfig }: Props) => (
<div className="setup__step"> <div className="setup__step">
<div className="setup__group"> <div className="setup__group">
<h1 className="setup__title"> <h1 className="setup__title">
@@ -29,27 +22,6 @@ let Submit = (props: SubmitProps) => (
</p> </p>
</div> </div>
<Controls openDashboard={props.openDashboard} ip={props.webIp} port={props.webPort} /> <Controls openDashboard={openDashboard} ip={webConfig.ip} port={webConfig.port} />
</div> </div>
); );
const selector = formValueSelector('install');
Submit = connect((state) => {
const webIp = selector(state, 'web.ip');
const webPort = selector(state, 'web.port');
return {
webIp,
webPort,
};
})(Submit);
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.INSTALL,
destroyOnUnmount: false,
forceUnregisterOnUnmount: true,
}),
])(Submit);

View File

@@ -1,101 +1,80 @@
import React, { Component, Fragment } from 'react'; import React, { useEffect, Fragment } from 'react';
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import * as actionCreators from '../../actions/install'; import * as actionCreators from '../../actions/install';
import { getWebAddress } from '../../helpers/helpers'; import { getWebAddress } from '../../helpers/helpers';
import { INSTALL_FIRST_STEP, INSTALL_TOTAL_STEPS, ALL_INTERFACES_IP, DEBOUNCE_TIMEOUT } from '../../helpers/constants'; import { INSTALL_TOTAL_STEPS, ALL_INTERFACES_IP, DEBOUNCE_TIMEOUT } from '../../helpers/constants';
import Loading from '../../components/ui/Loading'; import Loading from '../../components/ui/Loading';
import Greeting from './Greeting'; import Greeting from './Greeting';
import { ConfigType, DnsConfig, Settings, WebConfig } from './Settings';
import Settings from './Settings'; import { Devices } from './Devices';
import { Submit } from './Submit';
import Auth from './Auth'; import { Progress } from './Progress';
import { Auth } from './Auth';
import Devices from './Devices';
import Submit from './Submit';
import Progress from './Progress';
import Toasts from '../../components/Toasts'; import Toasts from '../../components/Toasts';
import Footer from '../../components/ui/Footer'; import Footer from '../../components/ui/Footer';
import Icons from '../../components/ui/Icons'; import Icons from '../../components/ui/Icons';
import { Logo } from '../../components/ui/svg/logo'; import { Logo } from '../../components/ui/svg/logo';
import './Setup.css'; import './Setup.css';
import '../../components/ui/Tabler.css'; import '../../components/ui/Tabler.css';
import { InstallInterface, InstallState } from '../../initialState';
interface SetupProps { export const Setup = () => {
getDefaultAddresses: (...args: unknown[]) => unknown; const dispatch = useDispatch();
setAllSettings: (...args: unknown[]) => unknown;
checkConfig: (...args: unknown[]) => unknown;
nextStep: (...args: unknown[]) => unknown;
prevStep: (...args: unknown[]) => unknown;
install: {
step: number;
processingDefault: boolean;
web;
dns;
staticIp;
interfaces;
};
step?: number;
web?: object;
dns?: object;
}
class Setup extends Component<SetupProps> { const install = useSelector((state: InstallState) => state.install);
componentDidMount() { const { processingDefault, step, web, dns, staticIp, interfaces } = install;
this.props.getDefaultAddresses();
}
handleFormSubmit = (values: any) => { useEffect(() => {
const { staticIp, ...config } = values; dispatch(actionCreators.getDefaultAddresses());
}, []);
this.props.setAllSettings(config); const handleFormSubmit = (values: any) => {
const config = { ...values };
delete config.staticIp;
if (web.port && dns.port) {
dispatch(
actionCreators.setAllSettings({
web,
dns,
...config,
}),
);
}
}; };
handleFormChange = debounce((values) => { const checkConfig = debounce((values) => {
const { web, dns } = values; const { web, dns } = values;
if (values && web.port && dns.port) { if (values && web.port && dns.port) {
this.props.checkConfig({ web, dns, set_static_ip: false }); dispatch(actionCreators.checkConfig({ web, dns, set_static_ip: false }));
} }
}, DEBOUNCE_TIMEOUT); }, DEBOUNCE_TIMEOUT);
handleFix = (web: any, dns: any, set_static_ip: any) => { const handleFix = (web: WebConfig, dns: DnsConfig, set_static_ip: boolean) => {
this.props.checkConfig({ web, dns, set_static_ip }); dispatch(actionCreators.checkConfig({ web, dns, set_static_ip }));
}; };
openDashboard = (ip: any, port: any) => { const openDashboard = (ip: string, port: number) => {
let address = getWebAddress(ip, port); let address = getWebAddress(ip, port);
if (ip === ALL_INTERFACES_IP) { if (ip === ALL_INTERFACES_IP) {
address = getWebAddress(window.location.hostname, port); address = getWebAddress(window.location.hostname, port);
} }
window.location.replace(address); window.location.replace(address);
}; };
nextStep = () => { const handleNextStep = () => {
if (this.props.install.step < INSTALL_TOTAL_STEPS) { if (step < INSTALL_TOTAL_STEPS) {
this.props.nextStep(); dispatch(actionCreators.nextStep());
} }
}; };
prevStep = () => { const renderPage = (step: number, config: ConfigType, interfaces: InstallInterface[]) => {
if (this.props.install.step > INSTALL_FIRST_STEP) {
this.props.prevStep();
}
};
renderPage(step: any, config: any, interfaces: any) {
switch (step) { switch (step) {
case 1: case 1:
return <Greeting />; return <Greeting />;
@@ -105,55 +84,41 @@ class Setup extends Component<SetupProps> {
config={config} config={config}
initialValues={config} initialValues={config}
interfaces={interfaces} interfaces={interfaces}
onSubmit={this.nextStep} handleSubmit={handleNextStep}
onChange={this.handleFormChange} validateForm={checkConfig}
validateForm={this.handleFormChange} handleFix={handleFix}
handleFix={this.handleFix}
/> />
); );
case 3: case 3:
return <Auth onSubmit={this.handleFormSubmit} />; return <Auth onAuthSubmit={handleFormSubmit} />;
case 4: case 4:
return <Devices interfaces={interfaces} />; return <Devices interfaces={interfaces} dnsConfig={dns} />;
case 5: case 5:
return <Submit openDashboard={this.openDashboard} />; return <Submit openDashboard={openDashboard} webConfig={web} />;
default: default:
return false; return false;
} }
};
if (processingDefault) {
return <Loading />;
} }
render() { return (
const { processingDefault, step, web, dns, staticIp, interfaces } = this.props.install; <>
<div className="setup">
<div className="setup__container">
<Logo className="setup__logo" />
{renderPage(step, { web, dns, staticIp }, interfaces)}
<Progress step={step} />
</div>
</div>
return ( <Footer />
<Fragment>
{processingDefault && <Loading />}
{!processingDefault && (
<Fragment>
<div className="setup">
<div className="setup__container">
<Logo className="setup__logo" />
{this.renderPage(step, { web, dns, staticIp }, interfaces)}
<Progress step={step} />
</div>
</div>
<Footer /> <Toasts />
<Toasts /> <Icons />
</>
<Icons /> );
</Fragment>
)}
</Fragment>
);
}
}
const mapStateToProps = (state: any) => {
const { install, toasts } = state;
const props = { install, toasts };
return props;
}; };
export default connect(mapStateToProps, actionCreators)(Setup);

View File

@@ -8,7 +8,7 @@ import configureStore from '../configureStore';
import reducers from '../reducers/install'; import reducers from '../reducers/install';
import '../i18n'; import '../i18n';
import Setup from './Setup'; import { Setup } from './Setup';
import { InstallState } from '../initialState'; import { InstallState } from '../initialState';
const store = configureStore<InstallState>(reducers, {}); const store = configureStore<InstallState>(reducers, {});

View File

@@ -1,67 +1,84 @@
import React from 'react'; import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Field, reduxForm } from 'redux-form'; import { useTranslation } from 'react-i18next';
import { Trans, withTranslation } from 'react-i18next'; import { Input } from '../../components/ui/Controls/Input';
import flow from 'lodash/flow';
import { renderInputField } from '../../helpers/form';
import { validateRequiredValue } from '../../helpers/validators'; import { validateRequiredValue } from '../../helpers/validators';
import { FORM_NAME } from '../../helpers/constants';
interface LoginFormProps { export type LoginFormValues = {
handleSubmit: (...args: unknown[]) => string; username: string;
submitting: boolean; password: string;
invalid: boolean; };
type LoginFormProps = {
onSubmit: (data: LoginFormValues) => void;
processing: boolean; processing: boolean;
t: (...args: unknown[]) => string; };
}
const Form = (props: LoginFormProps) => { const Form = ({ onSubmit, processing }: LoginFormProps) => {
const { handleSubmit, processing, invalid, t } = props; const { t } = useTranslation();
const {
handleSubmit,
control,
formState: { isValid },
} = useForm<LoginFormValues>({
mode: 'onBlur',
defaultValues: {
username: '',
password: '',
},
});
return ( return (
<form onSubmit={handleSubmit} className="card"> <form onSubmit={handleSubmit(onSubmit)} className="card">
<div className="card-body p-6"> <div className="card-body p-6">
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label className="form__label" htmlFor="username"> <Controller
<Trans>username_label</Trans>
</label>
<Field
id="username1"
name="username" name="username"
type="text" control={control}
className="form-control" rules={{ validate: validateRequiredValue }}
component={renderInputField} render={({ field, fieldState }) => (
placeholder={t('username_placeholder')} <Input
autoComplete="username" {...field}
autocapitalize="none" data-testid="username"
disabled={processing} type="text"
validate={[validateRequiredValue]} label={t('username_label')}
placeholder={t('username_placeholder')}
error={fieldState.error?.message}
autoComplete="username"
autoCapitalize="none"
disabled={processing}
/>
)}
/> />
</div> </div>
<div className="form__group form__group--settings"> <div className="form__group form__group--settings">
<label className="form__label" htmlFor="password"> <Controller
<Trans>password_label</Trans>
</label>
<Field
id="password"
name="password" name="password"
type="password" control={control}
className="form-control" rules={{ validate: validateRequiredValue }}
component={renderInputField} render={({ field, fieldState }) => (
placeholder={t('password_placeholder')} <Input
autoComplete="current-password" {...field}
disabled={processing} data-testid="password"
validate={[validateRequiredValue]} type="password"
label={t('username_label')}
placeholder={t('password_placeholder')}
error={fieldState.error?.message}
autoComplete="current-password"
disabled={processing}
/>
)}
/> />
</div> </div>
<div className="form-footer"> <div className="form-footer">
<button type="submit" className="btn btn-success btn-block" disabled={processing || invalid}> <button
<Trans>sign_in</Trans> data-testid="sign_in"
type="submit"
className="btn btn-success btn-block"
disabled={processing || !isValid}>
{t('sign_in')}
</button> </button>
</div> </div>
</div> </div>
@@ -69,4 +86,4 @@ const Form = (props: LoginFormProps) => {
); );
}; };
export default flow([withTranslation(), reduxForm({ form: FORM_NAME.LOGIN })])(Form); export default Form;

View File

@@ -1,95 +1,68 @@
import React, { Component } from 'react'; import React, { useState } from 'react';
import { connect } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import flow from 'lodash/flow'; import { Trans } from 'react-i18next';
import { withTranslation, Trans } from 'react-i18next';
import * as actionCreators from '../../actions/login'; import * as actionCreators from '../../actions/login';
import { Logo } from '../../components/ui/svg/logo'; import { Logo } from '../../components/ui/svg/logo';
import Toasts from '../../components/Toasts'; import Toasts from '../../components/Toasts';
import Footer from '../../components/ui/Footer'; import Footer from '../../components/ui/Footer';
import Icons from '../../components/ui/Icons'; import Icons from '../../components/ui/Icons';
import Form, { LoginFormValues } from './Form';
import Form from './Form';
import './Login.css'; import './Login.css';
import '../../components/ui/Tabler.css'; import '../../components/ui/Tabler.css';
import { LoginState } from '../../initialState';
type LoginProps = { export const Login = () => {
login: { const dispatch = useDispatch();
processingLogin: boolean; const { processingLogin } = useSelector((state: LoginState) => state.login);
}; const [isForgotPasswordVisible, setIsForgotPasswordVisible] = useState(false);
processLogin: (args: { name: string; password: string }) => unknown;
};
type LoginState = { const handleSubmit = ({ username: name, password }: LoginFormValues) => {
isForgotPasswordVisible: boolean; dispatch(actionCreators.processLogin({ name, password }));
};
class Login extends Component<LoginProps, LoginState> {
state = {
isForgotPasswordVisible: false,
}; };
handleSubmit = ({ username: name, password }: { username: string; password: string }) => { const toggleText = () => {
this.props.processLogin({ name, password }); setIsForgotPasswordVisible((prev) => !prev);
}; };
toggleText = () => { return (
this.setState((prevState) => ({ <div className="login">
isForgotPasswordVisible: !prevState.isForgotPasswordVisible, <div className="login__form">
})); <div className="text-center mb-6">
}; <Logo className="h-6 login__logo" />
render() {
const { processingLogin } = this.props.login;
const { isForgotPasswordVisible } = this.state;
return (
<div className="login">
<div className="login__form">
<div className="text-center mb-6">
<Logo className="h-6 login__logo" />
</div>
<Form onSubmit={this.handleSubmit} processing={processingLogin} />
<div className="login__info">
<button type="button" className="btn btn-link login__link" onClick={this.toggleText}>
<Trans>forgot_password</Trans>
</button>
{isForgotPasswordVisible && (
<div className="login__message">
<Trans
components={[
<a
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#password-reset"
key="0"
target="_blank"
rel="noopener noreferrer">
link
</a>,
]}>
forgot_password_desc
</Trans>
</div>
)}
</div>
</div> </div>
<Footer /> <Form onSubmit={handleSubmit} processing={processingLogin} />
<Toasts /> <div className="login__info">
<button type="button" className="btn btn-link login__link" onClick={toggleText}>
<Trans>forgot_password</Trans>
</button>
<Icons /> {isForgotPasswordVisible && (
<div className="login__message">
<Trans
components={[
<a
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#password-reset"
key="0"
target="_blank"
rel="noopener noreferrer">
link
</a>,
]}>
forgot_password_desc
</Trans>
</div>
)}
</div>
</div> </div>
);
}
}
const mapStateToProps = ({ login, toasts }: any) => ({ login, toasts }); <Footer />
<Toasts />
export default flow([withTranslation(), connect(mapStateToProps, actionCreators)])(Login); <Icons />
</div>
);
};

View File

@@ -8,7 +8,7 @@ import configureStore from '../configureStore';
import reducers from '../reducers/login'; import reducers from '../reducers/login';
import '../i18n'; import '../i18n';
import Login from './Login'; import { Login } from './Login';
import { LoginState } from '../initialState'; import { LoginState } from '../initialState';
const store = configureStore<LoginState>(reducers, {}); const store = configureStore<LoginState>(reducers, {});

View File

@@ -1,7 +1,6 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { loadingBarReducer } from 'react-redux-loading-bar'; import { loadingBarReducer } from 'react-redux-loading-bar';
import { reducer as formReducer } from 'redux-form';
import toasts from './toasts'; import toasts from './toasts';
import encryption from './encryption'; import encryption from './encryption';
import clients from './clients'; import clients from './clients';
@@ -31,5 +30,4 @@ export default combineReducers({
stats, stats,
dnsConfig, dnsConfig,
loadingBar: loadingBarReducer, loadingBar: loadingBarReducer,
form: formReducer,
}); });

View File

@@ -2,23 +2,21 @@ import { combineReducers } from 'redux';
import { handleActions } from 'redux-actions'; import { handleActions } from 'redux-actions';
import { reducer as formReducer } from 'redux-form';
import * as actions from '../actions/install'; import * as actions from '../actions/install';
import toasts from './toasts'; import toasts from './toasts';
import { ALL_INTERFACES_IP, INSTALL_FIRST_STEP, STANDARD_DNS_PORT, STANDARD_WEB_PORT } from '../helpers/constants'; import { ALL_INTERFACES_IP, INSTALL_FIRST_STEP, STANDARD_DNS_PORT, STANDARD_WEB_PORT } from '../helpers/constants';
const install = handleActions( const install = handleActions(
{ {
[actions.getDefaultAddressesRequest.toString().toString()]: (state: any) => ({ [actions.getDefaultAddressesRequest.toString()]: (state: any) => ({
...state, ...state,
processingDefault: true, processingDefault: true,
}), }),
[actions.getDefaultAddressesFailure.toString().toString()]: (state: any) => ({ [actions.getDefaultAddressesFailure.toString()]: (state: any) => ({
...state, ...state,
processingDefault: false, processingDefault: false,
}), }),
[actions.getDefaultAddressesSuccess.toString().toString()]: (state: any, { payload }: any) => { [actions.getDefaultAddressesSuccess.toString()]: (state: any, { payload }: any) => {
const { interfaces, version } = payload; const { interfaces, version } = payload;
const web = { ...state.web, port: payload.web_port }; const web = { ...state.web, port: payload.web_port };
const dns = { ...state.dns, port: payload.dns_port }; const dns = { ...state.dns, port: payload.dns_port };
@@ -35,37 +33,37 @@ const install = handleActions(
return newState; return newState;
}, },
[actions.nextStep.toString().toString()]: (state: any) => ({ [actions.nextStep.toString()]: (state: any) => ({
...state, ...state,
step: state.step + 1, step: state.step + 1,
}), }),
[actions.prevStep.toString().toString()]: (state: any) => ({ [actions.prevStep.toString()]: (state: any) => ({
...state, ...state,
step: state.step - 1, step: state.step - 1,
}), }),
[actions.setAllSettingsRequest.toString().toString()]: (state: any) => ({ [actions.setAllSettingsRequest.toString()]: (state: any) => ({
...state, ...state,
processingSubmit: true, processingSubmit: true,
}), }),
[actions.setAllSettingsFailure.toString().toString()]: (state: any) => ({ [actions.setAllSettingsFailure.toString()]: (state: any) => ({
...state, ...state,
processingSubmit: false, processingSubmit: false,
}), }),
[actions.setAllSettingsSuccess.toString().toString()]: (state: any) => ({ [actions.setAllSettingsSuccess.toString()]: (state: any) => ({
...state, ...state,
processingSubmit: false, processingSubmit: false,
}), }),
[actions.checkConfigRequest.toString().toString()]: (state: any) => ({ [actions.checkConfigRequest.toString()]: (state: any) => ({
...state, ...state,
processingCheck: true, processingCheck: true,
}), }),
[actions.checkConfigFailure.toString().toString()]: (state: any) => ({ [actions.checkConfigFailure.toString()]: (state: any) => ({
...state, ...state,
processingCheck: false, processingCheck: false,
}), }),
[actions.checkConfigSuccess.toString().toString()]: (state: any, { payload }: any) => { [actions.checkConfigSuccess.toString()]: (state: any, { payload }: any) => {
const web = { ...state.web, ...payload.web }; const web = { ...state.web, ...payload.web };
const dns = { ...state.dns, ...payload.dns }; const dns = { ...state.dns, ...payload.dns };
const staticIp = { ...state.staticIp, ...payload.static_ip }; const staticIp = { ...state.staticIp, ...payload.static_ip };
@@ -110,5 +108,4 @@ const install = handleActions(
export default combineReducers({ export default combineReducers({
install, install,
toasts, toasts,
form: formReducer,
}); });

View File

@@ -2,8 +2,6 @@ import { combineReducers } from 'redux';
import { handleActions } from 'redux-actions'; import { handleActions } from 'redux-actions';
import { reducer as formReducer } from 'redux-form';
import * as actions from '../actions/login'; import * as actions from '../actions/login';
import toasts from './toasts'; import toasts from './toasts';
@@ -33,5 +31,4 @@ const login = handleActions(
export default combineReducers({ export default combineReducers({
login, login,
toasts, toasts,
form: formReducer,
}); });

View File

@@ -0,0 +1,4 @@
export const ADMIN_USERNAME = 'admin';
export const ADMIN_PASSWORD = 'superpassword';
export const PORT = 3000;
export const CONFIG_FILE_PATH = '/tmp/AdGuard.e2e.yaml';

View File

@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import { ADMIN_PASSWORD, ADMIN_USERNAME } from '../constants';
import { getDHCPConfig } from '../helpers/network';
const dhcpConfig = getDHCPConfig();
const INTERFACE_NAME = dhcpConfig.interfaceName;
const RANGE_START = dhcpConfig.rangeStart;
const RANGE_END = dhcpConfig.rangeEnd;
const SUBNET_MASK = dhcpConfig.subnetMask;
const LEASE_TIME = '86400';
test.describe('DHCP Configuration', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
await page.goto(`/#dhcp`);
});
test('should select the correct DHCP interface', async ({ page }) => {
await page.getByTestId('interface_name').selectOption(INTERFACE_NAME);
expect(await page.locator('select[name="interface_name"]').inputValue()).toBe(INTERFACE_NAME);
});
test('should configure DHCP IPv4 settings correctly', async ({ page }) => {
await page.getByTestId('interface_name').selectOption(INTERFACE_NAME);
await page.getByTestId('v4_gateway_ip').click();
await page.getByTestId('v4_gateway_ip').fill('192.168.1.99');
await page.getByTestId('v4_subnet_mask').click();
await page.getByTestId('v4_subnet_mask').fill(SUBNET_MASK);
await page.getByTestId('v4_range_start').click();
await page.getByTestId('v4_range_start').fill(RANGE_START);
await page.getByTestId('v4_range_end').click();
await page.getByTestId('v4_range_end').fill(RANGE_END);
await page.getByTestId('v4_lease_duration').click();
await page.getByTestId('v4_lease_duration').fill(LEASE_TIME);
await page.getByTestId('v4_save').click();
});
test('should show error for invalid DHCP IPv4 range', async ({ page }) => {
await page.getByTestId('interface_name').selectOption(INTERFACE_NAME);
await page.getByTestId('v4_range_start').click();
await page.getByTestId('v4_range_start').fill(RANGE_END);
await page.getByTestId('v4_range_end').click();
await page.getByTestId('v4_range_end').fill(RANGE_START);
await page.keyboard.press('Tab');
expect(await page.getByText('Must be greater than range').isVisible()).toBe(true);
});
test('should show error for invalid DHCP IPv4 address', async ({ page }) => {
await page.getByTestId('interface_name').selectOption(INTERFACE_NAME);
await page.getByTestId('v4_gateway_ip').click();
await page.getByTestId('v4_gateway_ip').fill('192.168.1.200s');
await page.keyboard.press('Tab');
expect(await page.getByText('Invalid IPv4 address').isVisible()).toBe(true);
});
});

View File

@@ -0,0 +1,31 @@
import { chromium, type FullConfig } from '@playwright/test';
import { ADMIN_USERNAME, ADMIN_PASSWORD, PORT } from '../constants';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch({
slowMo: 100,
});
const page = await browser.newPage({ baseURL: config.webServer?.url });
try {
await page.goto('/');
await page.getByTestId('install_get_started').click();
await page.getByTestId('install_web_port').fill(PORT.toString());
await page.getByTestId('install_next').click();
await page.getByTestId('install_username').fill(ADMIN_USERNAME);
await page.getByTestId('install_password').fill(ADMIN_PASSWORD);
await page.getByTestId('install_confirm_password').click();
await page.getByTestId('install_confirm_password').fill(ADMIN_PASSWORD);
await page.getByTestId('install_next').click();
await page.getByTestId('install_next').click();
await page.getByTestId('install_open_dashboard').click();
await page.waitForURL((url) => !url.href.endsWith('/install.html'));
} catch (error) {
console.error('Error during global setup:', error);
} finally {
await browser.close();
}
}
export default globalSetup;

View File

@@ -0,0 +1,11 @@
import { existsSync, unlinkSync } from 'fs';
import { CONFIG_FILE_PATH } from '../constants';
async function globalTeardown() {
// Remove the test config file
if (existsSync(CONFIG_FILE_PATH)) {
unlinkSync(CONFIG_FILE_PATH);
}
}
export default globalTeardown;

View File

@@ -0,0 +1,16 @@
import { test } from '@playwright/test';
import { ADMIN_PASSWORD, ADMIN_USERNAME } from '../constants';
test.describe('Login', () => {
test('should successfully log in with valid credentials', async ({ page }) => {
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
});
});

View File

@@ -0,0 +1,65 @@
import { networkInterfaces } from 'os';
import type { NetworkInterfaceInfo } from 'node:os';
interface DHCPConfig {
interfaceName: string;
rangeStart: string;
rangeEnd: string;
subnetMask: string;
}
const DEFAULT_SUBNET_MASK = '255.255.255.0';
const DEFAULT_SUBNET_MASK_OCTETS = DEFAULT_SUBNET_MASK.split('.').map(Number);
function checkIsIPv4(addr: NetworkInterfaceInfo): boolean {
return addr.family === 'IPv4' && !addr.internal;
}
function calculateNetwork(ip: number[], mask: number[]): number[] {
// Calculate the network address by applying the bitwise AND operation.
// eslint-disable-next-line no-bitwise
return ip.map((octet, i) => octet & mask[i]);
}
function calculateBroadcast(network: number[], mask: number[]): number[] {
// Calculate the broadcast address by ORing the network address with the inverted mask.
// eslint-disable-next-line no-bitwise
return network.map((octet, i) => octet | (~mask[i] & 255));
}
export function getDHCPConfig(): DHCPConfig {
const interfaces = networkInterfaces();
// Select the first interface that has a valid non-internal IPv4 address.
const ipV4Interface = Object.entries(interfaces)
.map(([name, addresses]) => ({ name, addresses }))
.find((i) => i.addresses?.some(checkIsIPv4));
if (!ipV4Interface) {
throw new Error('No suitable network interface found');
}
// Get the first valid IPv4 address from the interface.
const ipv4Address = ipV4Interface.addresses.find(checkIsIPv4);
const ip = ipv4Address.address.split('.').map(Number);
const mask = ipv4Address.netmask?.split('.').map(Number) || DEFAULT_SUBNET_MASK_OCTETS;
const network = calculateNetwork(ip, mask);
// Calculate first usable address (network address + 1)
const rangeStart = [...network];
rangeStart[3] = network[3] + 1;
// Calculate broadcast address and then the last usable address (broadcast - 1)
const broadcast = calculateBroadcast(network, mask);
const rangeEnd = [...broadcast];
rangeEnd[3] = broadcast[3] - 1;
return {
interfaceName: ipV4Interface.name,
rangeStart: rangeStart.join('.'),
rangeEnd: rangeEnd.join('.'),
subnetMask: ipv4Address.netmask || DEFAULT_SUBNET_MASK,
};
}

8
client/vitest.config.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['src/__tests__/**'],
},
});

Some files were not shown because too many files have changed in this diff Show More