mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2025-05-02 06:00:25 +03:00
Pull request 2322: ADG-9415
Merge in DNS/adguard-home from ADG-9415 to master Squashed commit of the following: commit76bf99499a
Merge:29529970a
0389515ee
Author: Ildar Kamalov <ik@adguard.com> Date: Wed Feb 26 18:31:41 2025 +0300 Merge branch 'master' into ADG-9415 commit29529970a3
Merge:b49790daf
782a1a982
Author: Ainar Garipov <A.Garipov@AdGuard.COM> Date: Mon Feb 24 15:44:38 2025 +0300 Merge branch 'master' into ADG-9415 commitb49790daf8
Author: Ildar Kamalov <ik@adguard.com> Date: Mon Feb 24 15:30:18 2025 +0300 fix default lease duration value commitcb307472ec
Author: Ildar Kamalov <ik@adguard.com> Date: Mon Feb 24 10:35:26 2025 +0300 fix default response status commit115e743e1a
Author: Ildar Kamalov <ik@adguard.com> Date: Mon Feb 24 10:32:46 2025 +0300 fix upstream description commit26b0eddaca
Author: Ildar Kamalov <ik@adguard.com> Date: Tue Feb 18 17:40:41 2025 +0300 use const for test config file commit58faa7c537
Author: Ildar Kamalov <ik@adguard.com> Date: Tue Feb 18 17:31:04 2025 +0300 fix install config commit0a3346d911
Author: Ildar Kamalov <ik@adguard.com> Date: Mon Feb 17 15:25:23 2025 +0300 fix install check config commit17c4c26ea8
Author: Ildar Kamalov <ik@adguard.com> Date: Fri Feb 14 17:18:20 2025 +0300 fix query log commit14a2685ae3
Author: Ildar Kamalov <ik@adguard.com> Date: Fri Feb 14 15:52:36 2025 +0300 fix dhcp initial values commite7a8db7afd
Author: Ildar Kamalov <ik@adguard.com> Date: Fri Feb 14 14:37:24 2025 +0300 fix encryption form values commit1c8917f7ac
Author: Ildar Kamalov <ik@adguard.com> Date: Fri Feb 14 14:07:29 2025 +0300 fix blocked services submit commit4dfa536cea
Author: Ildar Kamalov <ik@adguard.com> Date: Fri Feb 14 13:50:47 2025 +0300 dns config ip validation commit4fee83fe13
Author: Ildar Kamalov <ik@adguard.com> Date: Wed Feb 12 17:49:54 2025 +0300 add playwright warning commit8c2f36e7a6
Author: Ildar Kamalov <ik@adguard.com> Date: Tue Feb 11 18:36:18 2025 +0300 fix config file name commit83db5f33dc
Author: Ildar Kamalov <ik@adguard.com> Date: Tue Feb 11 16:16:43 2025 +0300 temp config file commit9080c1620f
Author: Ildar Kamalov <ik@adguard.com> Date: Tue Feb 11 15:01:46 2025 +0300 update readme commitee1520307f
Merge:fd12e33c0
2fe2d254b
Author: Ildar Kamalov <ik@adguard.com> Date: Tue Feb 11 14:44:06 2025 +0300 Merge branch 'master' into ADG-9415 commitfd12e33c06
Author: Igor Lobanov <bniwredyc@gmail.com> Date: Mon Feb 10 10:29:43 2025 +0100 added typecheck on build, fixed eslint commitb3849eebc4
Merge:225167a8b
9bf3ee128
Author: Igor Lobanov <bniwredyc@gmail.com> Date: Mon Feb 10 09:43:32 2025 +0100 Merge branch 'ADG-9415' of https://bit.int.agrd.dev/scm/dns/adguard-home into ADG-9415 ... and 94 more commits
This commit is contained in:
parent
0389515ee3
commit
8b2ab8ea87
102 changed files with 7075 additions and 10256 deletions
.github/workflows
.gitignoreMakefileREADME.mdbamboo-specs
client
.eslintrc.jsonjest.config.mjspackage-lock.jsonpackage.jsonplaywright.config.ts
src
__tests__
actions
components
Filters
Logs
Settings
Clients
Dhcp
Dns
Encryption
FiltersConfig
LogsConfig
Settings.cssStatsConfig
index.tsxSetupGuide
ui
containers
helpers
initialState.tsinstall
Setup
index.tsxlogin
reducers
tests
vitest.config.ts
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
@ -2,7 +2,7 @@
|
|||
|
||||
'env':
|
||||
'GO_VERSION': '1.23.6'
|
||||
'NODE_VERSION': '16'
|
||||
'NODE_VERSION': '18'
|
||||
|
||||
'on':
|
||||
'push':
|
||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -19,6 +19,10 @@
|
|||
/agh-backup/
|
||||
/bin/
|
||||
/build/*
|
||||
/client/blob-report/
|
||||
/client/playwright-report/
|
||||
/client/playwright/.cache/
|
||||
/client/test-results/
|
||||
/data/
|
||||
/dist/
|
||||
/filtering/tests/filtering.TestLotsOfRules*.pprof
|
||||
|
|
13
Makefile
13
Makefile
|
@ -32,8 +32,7 @@ GPG_KEY = devteam@adguard.com
|
|||
GPG_KEY_PASSPHRASE = not-a-real-password
|
||||
NPM = npm
|
||||
NPM_FLAGS = --prefix $(CLIENT_DIR)
|
||||
NPM_INSTALL_FLAGS = $(NPM_FLAGS) --quiet --no-progress --ignore-engines\
|
||||
--ignore-optional --ignore-platform --ignore-scripts
|
||||
NPM_INSTALL_FLAGS = $(NPM_FLAGS) --quiet --no-progress
|
||||
RACE = 0
|
||||
REVISION = $${REVISION:-$$(git rev-parse --short HEAD)}
|
||||
SIGN = 1
|
||||
|
@ -105,10 +104,12 @@ build-docker: ; $(ENV) "$(SHELL)" ./scripts/make/build-docker.sh
|
|||
build-release: $(BUILD_RELEASE_DEPS_$(FRONTEND_PREBUILT))
|
||||
$(ENV) "$(SHELL)" ./scripts/make/build-release.sh
|
||||
|
||||
js-build: ; $(NPM) $(NPM_FLAGS) run build-prod
|
||||
js-deps: ; $(NPM) $(NPM_INSTALL_FLAGS) ci
|
||||
js-lint: ; $(NPM) $(NPM_FLAGS) run lint
|
||||
js-test: ; $(NPM) $(NPM_FLAGS) run test
|
||||
js-build: ; $(NPM) $(NPM_FLAGS) run build-prod
|
||||
js-deps: ; $(NPM) $(NPM_INSTALL_FLAGS) ci
|
||||
js-typecheck: ; $(NPM) $(NPM_FLAGS) run typecheck
|
||||
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-build: ; $(ENV) "$(SHELL)" ./scripts/make/go-build.sh
|
||||
|
|
16
README.md
16
README.md
|
@ -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-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>
|
||||
|
||||
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.
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
# Make sure to sync any changes with the branch overrides below.
|
||||
'variables':
|
||||
'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'
|
||||
|
||||
'stages':
|
||||
|
@ -277,7 +277,7 @@
|
|||
# need to build a few of these.
|
||||
'variables':
|
||||
'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'
|
||||
# release-vX.Y.Z branches are the branches from which the actual final
|
||||
# release is built.
|
||||
|
@ -293,5 +293,5 @@
|
|||
# are the ones that actually get released.
|
||||
'variables':
|
||||
'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'
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
'key': 'AHBRTSPECS'
|
||||
'name': 'AdGuard Home - Build and run tests'
|
||||
'variables':
|
||||
'dockerFrontend': 'adguard/home-js-builder:2.0'
|
||||
'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
|
||||
'dockerGo': 'adguard/go-builder:1.23.6--1'
|
||||
'channel': 'development'
|
||||
|
||||
|
@ -29,6 +29,12 @@
|
|||
jobs:
|
||||
- 'Artifact'
|
||||
|
||||
- 'E2E':
|
||||
manual: false
|
||||
final: false
|
||||
jobs:
|
||||
- 'Test e2e'
|
||||
|
||||
'Test frontend':
|
||||
'docker':
|
||||
'image': '${bamboo.dockerFrontend}'
|
||||
|
@ -48,7 +54,7 @@
|
|||
|
||||
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':
|
||||
- 'clean'
|
||||
'requirements':
|
||||
|
@ -165,6 +171,38 @@
|
|||
'requirements':
|
||||
- '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':
|
||||
'create': 'for-pull-request'
|
||||
'delete':
|
||||
|
@ -195,6 +233,6 @@
|
|||
# Set the default release channel on the release branch to beta, as we
|
||||
# may need to build a few of these.
|
||||
'variables':
|
||||
'dockerFrontend': 'adguard/home-js-builder:2.0'
|
||||
'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
|
||||
'dockerGo': 'adguard/go-builder:1.23.6--1'
|
||||
'channel': 'candidate'
|
||||
|
|
22
client/.eslintrc.json
vendored
22
client/.eslintrc.json
vendored
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"plugins": ["prettier"],
|
||||
"plugins": [
|
||||
"prettier"
|
||||
],
|
||||
"extends": [
|
||||
"airbnb-base",
|
||||
"prettier",
|
||||
|
@ -21,12 +23,23 @@
|
|||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [".js", ".jsx", ".ts", ".tsx"]
|
||||
"extensions": [
|
||||
".js",
|
||||
".jsx",
|
||||
".ts",
|
||||
".tsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
|
@ -43,7 +56,10 @@
|
|||
"no-console": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["warn", "error"]
|
||||
"allow": [
|
||||
"warn",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
],
|
||||
"import/no-extraneous-dependencies": [
|
||||
|
|
6
client/jest.config.mjs
vendored
6
client/jest.config.mjs
vendored
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
testEnvironment: 'jsdom',
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'babel-jest',
|
||||
},
|
||||
};
|
8728
client/package-lock.json
generated
vendored
8728
client/package-lock.json
generated
vendored
File diff suppressed because it is too large
Load diff
23
client/package.json
vendored
23
client/package.json
vendored
|
@ -7,11 +7,14 @@
|
|||
"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:hot": "cross-env BUILD_ENV=dev webpack-dev-server --config webpack.dev.js",
|
||||
"lint": "echo 'Lint temporarily disabled'",
|
||||
"lint-new": "eslint './src/**/*.(ts|tsx)'",
|
||||
"lint:fix": "eslint './src/**/*.(ts|tsx)' --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint --ext .ts,.tsx src",
|
||||
"lint:fix": "eslint --ext .ts,.tsx src --fix",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest --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:watch": "tsc --noEmit --watch"
|
||||
},
|
||||
|
@ -20,6 +23,7 @@
|
|||
"@nivo/line": "^0.64.0",
|
||||
"axios": "^0.19.2",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"countries-and-timezones": "^3.6.0",
|
||||
"date-fns": "^1.29.0",
|
||||
"i18next": "^19.6.2",
|
||||
|
@ -34,6 +38,7 @@
|
|||
"react": "^16.13.1",
|
||||
"react-click-outside": "^3.0.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-i18next": "^11.7.2",
|
||||
"react-modal": "^3.11.2",
|
||||
"react-popper-tooltip": "^2.11.1",
|
||||
|
@ -46,7 +51,6 @@
|
|||
"react-transition-group": "^4.4.5",
|
||||
"redux": "^4.0.5",
|
||||
"redux-actions": "^2.6.5",
|
||||
"redux-form": "^8.3.10",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"ts-migrate": "^0.1.35",
|
||||
"url-polyfill": "^1.1.12"
|
||||
|
@ -60,15 +64,15 @@
|
|||
"@babel/plugin-transform-runtime": "^7.24.3",
|
||||
"@babel/preset-env": "^7.24.5",
|
||||
"@babel/preset-react": "^7.24.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^17.0.80",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-redux": "^7.1.33",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/redux-actions": "^2.6.5",
|
||||
"@types/redux-form": "^8.3.10",
|
||||
"@typescript-eslint/eslint-plugin": "^7.11.0",
|
||||
"@typescript-eslint/parser": "^7.10.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
|
@ -85,8 +89,6 @@
|
|||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jscodeshift": "^0.15.2",
|
||||
"mini-css-extract-plugin": "^2.9.0",
|
||||
"path": "^0.12.7",
|
||||
|
@ -97,6 +99,7 @@
|
|||
"stylelint": "^16.5.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"url-loader": "^4.1.1",
|
||||
"vitest": "^3.0.4",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.0.4",
|
||||
|
|
52
client/playwright.config.ts
vendored
Normal file
52
client/playwright.config.ts
vendored
Normal 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,
|
||||
},
|
||||
});
|
|
@ -1,3 +1,5 @@
|
|||
import { describe, expect, test, afterEach, vi, beforeEach, it } from 'vitest';
|
||||
|
||||
import { sortIp, countClientsStatistics, findAddressType, subnetMaskToBitMask } from '../helpers/helpers';
|
||||
import { ADDRESS_TYPES } from '../helpers/constants';
|
||||
|
||||
|
@ -259,7 +261,7 @@ describe('sortIp', () => {
|
|||
const originalWarn = console.warn;
|
||||
|
||||
beforeEach(() => {
|
||||
console.warn = jest.fn();
|
||||
console.warn = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -347,15 +349,15 @@ describe('sortIp', () => {
|
|||
});
|
||||
|
||||
describe('findAddressType', () => {
|
||||
describe('ip', () => {
|
||||
it('should return IP type for IP addresses', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
describe('mac', () => {
|
||||
it('should return UNKNOWN type for MAC addresses', () => {
|
||||
expect(findAddressType('00:1B:44:11:3A:B7')).toStrictEqual(ADDRESS_TYPES.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,7 +19,6 @@ import {
|
|||
CHECK_TIMEOUT,
|
||||
STATUS_RESPONSE,
|
||||
SETTINGS_NAMES,
|
||||
FORM_NAME,
|
||||
MANUAL_UPDATE_LINK,
|
||||
DISABLE_PROTECTION_TIMINGS,
|
||||
} 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 { bootstrap_dns, upstream_dns, local_ptr_upstreams, fallback_dns } =
|
||||
getState().form[FORM_NAME.UPSTREAM].values;
|
||||
const { bootstrap_dns, upstream_dns, local_ptr_upstreams, fallback_dns } = formValues;
|
||||
|
||||
return dispatch(
|
||||
testUpstream(
|
||||
|
@ -512,16 +510,15 @@ export const findActiveDhcpRequest = createAction('FIND_ACTIVE_DHCP_REQUEST');
|
|||
export const findActiveDhcpSuccess = createAction('FIND_ACTIVE_DHCP_SUCCESS');
|
||||
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());
|
||||
try {
|
||||
const req = {
|
||||
interface: name,
|
||||
interface: selectedInterface,
|
||||
};
|
||||
const activeDhcp = await apiClient.findActiveDhcp(req);
|
||||
dispatch(findActiveDhcpSuccess(activeDhcp));
|
||||
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 v6 = check?.v6 ?? { other_server: {} };
|
||||
|
||||
|
|
|
@ -27,7 +27,8 @@ export const setAllSettingsSuccess = createAction('SET_ALL_SETTINGS_SUCCESS');
|
|||
export const setAllSettings = (values: any) => async (dispatch: any) => {
|
||||
dispatch(setAllSettingsRequest());
|
||||
try {
|
||||
const { confirm_password, ...config } = values;
|
||||
const config = { ...values };
|
||||
delete config.confirm_password;
|
||||
|
||||
await apiClient.setAllSettings(config);
|
||||
dispatch(setAllSettingsSuccess());
|
||||
|
@ -48,7 +49,11 @@ export const checkConfig = (values: any) => async (dispatch: any) => {
|
|||
dispatch(checkConfigRequest());
|
||||
try {
|
||||
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) {
|
||||
dispatch(addErrorToast({ error }));
|
||||
dispatch(checkConfigFailure());
|
||||
|
|
|
@ -3,8 +3,9 @@ import { createAction } from 'redux-actions';
|
|||
import apiClient from '../api/Api';
|
||||
|
||||
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 { SearchFormValues } from '../components/Logs';
|
||||
|
||||
const getLogsWithParams = async (config: any) => {
|
||||
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 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 totalData = total || { logs };
|
||||
|
||||
const queryForm = getState().form[FORM_NAME.LOGS_FILTER];
|
||||
const currentQuery = queryForm && queryForm.values.search;
|
||||
const previousQuery = filter?.search;
|
||||
const isQueryTheSame =
|
||||
typeof previousQuery === 'string' && typeof currentQuery === 'string' && previousQuery === currentQuery;
|
||||
|
@ -51,7 +50,7 @@ const shortPollQueryLogs = async (data: any, filter: any, dispatch: any, getStat
|
|||
filter,
|
||||
});
|
||||
if (additionalLogs.oldest.length > 0) {
|
||||
return await shortPollQueryLogs(additionalLogs, filter, dispatch, getState, {
|
||||
return await shortPollQueryLogs(additionalLogs, filter, dispatch, currentQuery, {
|
||||
logs: [...totalData.logs, ...additionalLogs.logs],
|
||||
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());
|
||||
try {
|
||||
const { isFiltered, filter, oldest } = getState().queryLogs;
|
||||
|
||||
const data = await getLogsWithParams({
|
||||
older_than: oldest,
|
||||
filter,
|
||||
});
|
||||
|
||||
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;
|
||||
dispatch(getLogsSuccess(updatedData));
|
||||
} else {
|
||||
|
@ -122,13 +122,13 @@ export const setLogsFilterRequest = createAction('SET_LOGS_FILTER_REQUEST');
|
|||
* @param {string} filter.response_status 'QUERY' field of RESPONSE_FILTER object
|
||||
* @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 setFilteredLogsFailure = createAction('SET_FILTERED_LOGS_FAILURE');
|
||||
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());
|
||||
try {
|
||||
const data = await getLogsWithParams({
|
||||
|
@ -136,7 +136,9 @@ export const setFilteredLogs = (filter?: any) => async (dispatch: any, getState:
|
|||
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;
|
||||
|
||||
dispatch(
|
||||
|
|
|
@ -1,62 +1,74 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import Card from '../../ui/Card';
|
||||
|
||||
import { renderInputField } from '../../../helpers/form';
|
||||
|
||||
import Info from './Info';
|
||||
import { FORM_NAME } from '../../../helpers/constants';
|
||||
import { RootState } from '../../../initialState';
|
||||
|
||||
interface CheckProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
pristine: boolean;
|
||||
invalid: boolean;
|
||||
import { RootState } from '../../../initialState';
|
||||
import { validateRequiredValue } from '../../../helpers/validators';
|
||||
import { Input } from '../../ui/Controls/Input';
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const Check = (props: CheckProps) => {
|
||||
const { pristine, invalid, handleSubmit } = props;
|
||||
type Props = {
|
||||
onSubmit?: (data: FormValues) => void;
|
||||
};
|
||||
|
||||
const Check = ({ onSubmit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const processingCheck = useSelector((state: RootState) => state.filtering.processingCheck);
|
||||
|
||||
const hostname = useSelector((state: RootState) => state.filtering.check.hostname);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card title={t('check_title')} subtitle={t('check_desc')}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="row">
|
||||
<div className="col-12 col-md-6">
|
||||
<div className="input-group">
|
||||
<Field
|
||||
id="name"
|
||||
name="name"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_host')}
|
||||
/>
|
||||
|
||||
<span className="input-group-append">
|
||||
<button
|
||||
className="btn btn-success btn-standard btn-large"
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={pristine || invalid || processingCheck}>
|
||||
{t('check')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
data-testid="check_domain_name"
|
||||
placeholder={t('form_enter_host')}
|
||||
error={fieldState.error?.message}
|
||||
rightAddon={
|
||||
<span className="input-group-append">
|
||||
<button
|
||||
className="btn btn-success btn-standard btn-large"
|
||||
type="submit"
|
||||
data-testid="check_domain_submit"
|
||||
disabled={!isDirty || !isValid || processingCheck}>
|
||||
{t('check')}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{hostname && (
|
||||
<>
|
||||
<hr />
|
||||
|
||||
<Info />
|
||||
</>
|
||||
)}
|
||||
|
@ -67,4 +79,4 @@ const Check = (props: CheckProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default reduxForm({ form: FORM_NAME.DOMAIN_CHECK })(Check);
|
||||
export default Check;
|
||||
|
|
94
client/src/components/Filters/FiltersList.tsx
Normal file
94
client/src/components/Filters/FiltersList.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,208 +1,152 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
import classNames from 'classnames';
|
||||
import { useForm, Controller, FormProvider } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { validatePath, validateRequiredValue } from '../../helpers/validators';
|
||||
|
||||
import { CheckboxField, renderInputField } from '../../helpers/form';
|
||||
import { MODAL_OPEN_TIMEOUT, MODAL_TYPE, FORM_NAME } from '../../helpers/constants';
|
||||
import { MODAL_OPEN_TIMEOUT, MODAL_TYPE } from '../../helpers/constants';
|
||||
import filtersCatalog from '../../helpers/filters/filters';
|
||||
import { FiltersList } from './FiltersList';
|
||||
import { Input } from '../ui/Controls/Input';
|
||||
|
||||
const getIconsData = (homepage: any, source: any) => [
|
||||
{
|
||||
iconName: 'dashboard',
|
||||
href: homepage,
|
||||
className: 'ml-1',
|
||||
},
|
||||
{
|
||||
iconName: 'info',
|
||||
href: source,
|
||||
},
|
||||
];
|
||||
type FormValues = {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const renderIcons = (iconsData: any) =>
|
||||
iconsData.map(({ iconName, href, className = '' }: any) => (
|
||||
<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>
|
||||
));
|
||||
const defaultValues: FormValues = {
|
||||
enabled: true,
|
||||
name: '',
|
||||
url: '',
|
||||
};
|
||||
|
||||
interface renderCheckboxFieldProps {
|
||||
// https://redux-form.com/8.3.0/docs/api/field.md/#props
|
||||
input: {
|
||||
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;
|
||||
type Props = {
|
||||
closeModal: () => void;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
processingAddFilter: boolean;
|
||||
processingConfigFilter: boolean;
|
||||
whitelist?: boolean;
|
||||
modalType: string;
|
||||
toggleFilteringModal: (...args: unknown[]) => unknown;
|
||||
selectedSources?: object;
|
||||
}
|
||||
toggleFilteringModal: ({ type }: { type?: keyof typeof MODAL_TYPE }) => void;
|
||||
selectedSources?: Record<string, boolean>;
|
||||
initialValues?: FormValues;
|
||||
};
|
||||
|
||||
const Form = (props: FormProps) => {
|
||||
const {
|
||||
t,
|
||||
closeModal,
|
||||
handleSubmit,
|
||||
processingAddFilter,
|
||||
processingConfigFilter,
|
||||
whitelist,
|
||||
modalType,
|
||||
toggleFilteringModal,
|
||||
selectedSources,
|
||||
} = props;
|
||||
export const Form = ({
|
||||
closeModal,
|
||||
processingAddFilter,
|
||||
processingConfigFilter,
|
||||
whitelist,
|
||||
modalType,
|
||||
toggleFilteringModal,
|
||||
selectedSources,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const openModal = (modalType: any, timeout = MODAL_OPEN_TIMEOUT) => {
|
||||
toggleFilteringModal();
|
||||
const methods = useForm({
|
||||
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);
|
||||
};
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body modal-body--filters">
|
||||
{modalType === MODAL_TYPE.SELECT_MODAL_TYPE && (
|
||||
<div className="d-flex justify-content-around">
|
||||
<button
|
||||
onClick={openFilteringListModal}
|
||||
className="btn btn-success btn-standard mr-2 btn-large">
|
||||
{t('choose_from_list')}
|
||||
</button>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="modal-body modal-body--filters">
|
||||
{modalType === MODAL_TYPE.SELECT_MODAL_TYPE && (
|
||||
<div className="d-flex justify-content-around">
|
||||
<button
|
||||
onClick={openFilteringListModal}
|
||||
className="btn btn-success btn-standard mr-2 btn-large">
|
||||
{t('choose_from_list')}
|
||||
</button>
|
||||
|
||||
<button onClick={openAddFiltersModal} className="btn btn-primary btn-standard">
|
||||
{t('add_custom_list')}
|
||||
</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()}
|
||||
/>
|
||||
<button onClick={openAddFiltersModal} className="btn btn-primary btn-standard">
|
||||
{t('add_custom_list')}
|
||||
</button>
|
||||
</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">
|
||||
<Field
|
||||
id="url"
|
||||
name="url"
|
||||
type="text"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={t('enter_url_or_path_hint')}
|
||||
validate={[validateRequiredValue, validatePath]}
|
||||
normalizeOnBlur={(data: any) => data.trim()}
|
||||
/>
|
||||
</div>
|
||||
<div className="form__group">
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
rules={{ validate: { validateRequiredValue, validatePath } }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
data-testid="filters_url"
|
||||
placeholder={t('enter_url_or_path_hint')}
|
||||
error={fieldState.error?.message}
|
||||
trimOnBlur
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form__description">
|
||||
{whitelist ? t('enter_valid_allowlist') : t('enter_valid_blocklist')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="form__description">
|
||||
{whitelist ? t('enter_valid_allowlist') : t('enter_valid_blocklist')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={closeModal}>
|
||||
{t('cancel_btn')}
|
||||
</button>
|
||||
|
||||
{modalType !== MODAL_TYPE.SELECT_MODAL_TYPE && (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-success"
|
||||
disabled={processingAddFilter || processingConfigFilter}>
|
||||
{t('save_btn')}
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={closeModal}>
|
||||
{t('cancel_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{modalType !== MODAL_TYPE.SELECT_MODAL_TYPE && (
|
||||
<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);
|
||||
|
|
|
@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next';
|
|||
|
||||
import { MODAL_TYPE } from '../../helpers/constants';
|
||||
|
||||
import Form from './Form';
|
||||
import { Form } from './Form';
|
||||
import '../ui/Modal.css';
|
||||
|
||||
import { getMap } from '../../helpers/helpers';
|
||||
|
@ -75,25 +75,15 @@ class Modal extends Component<ModalProps> {
|
|||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
|
||||
processingAddFilter,
|
||||
|
||||
processingConfigFilter,
|
||||
|
||||
handleSubmit,
|
||||
|
||||
modalType,
|
||||
|
||||
currentFilterData,
|
||||
|
||||
whitelist,
|
||||
|
||||
toggleFilteringModal,
|
||||
|
||||
filters,
|
||||
|
||||
t,
|
||||
|
||||
filtersCatalog,
|
||||
} = this.props;
|
||||
|
||||
|
|
|
@ -1,42 +1,69 @@
|
|||
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 { FORM_NAME } from '../../../helpers/constants';
|
||||
import { Input } from '../../ui/Controls/Input';
|
||||
|
||||
interface FormProps {
|
||||
pristine: boolean;
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
reset: (...args: unknown[]) => string;
|
||||
toggleRewritesModal: (...args: unknown[]) => unknown;
|
||||
submitting: boolean;
|
||||
processingAdd: boolean;
|
||||
t: (...args: unknown[]) => string;
|
||||
initialValues?: object;
|
||||
interface RewriteFormValues {
|
||||
domain: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
const Form = (props: FormProps) => {
|
||||
const { t, handleSubmit, reset, pristine, submitting, toggleRewritesModal, processingAdd } = props;
|
||||
type 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 (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="modal-body">
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>domain_desc</Trans>
|
||||
</div>
|
||||
<div className="form__group">
|
||||
<Field
|
||||
id="domain"
|
||||
<Controller
|
||||
name="domain"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('form_domain')}
|
||||
validate={[validateRequiredValue, validateDomain]}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
validate: validateDomain,
|
||||
required: validateRequiredValue,
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
data-testid="rewrites_domain"
|
||||
placeholder={t('form_domain')}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Trans>examples_title</Trans>:
|
||||
|
@ -44,7 +71,6 @@ const Form = (props: FormProps) => {
|
|||
<li>
|
||||
<code>example.org</code> – <Trans>example_rewrite_domain</Trans>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<code>*.example.org</code> –
|
||||
<span>
|
||||
|
@ -53,14 +79,24 @@ const Form = (props: FormProps) => {
|
|||
</li>
|
||||
</ol>
|
||||
<div className="form__group">
|
||||
<Field
|
||||
id="answer"
|
||||
<Controller
|
||||
name="answer"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('form_answer')}
|
||||
validate={[validateRequiredValue, validateAnswer]}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
validate: validateAnswer,
|
||||
required: validateRequiredValue,
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
data-testid="rewrites_answer"
|
||||
placeholder={t('form_answer')}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -77,8 +113,9 @@ const Form = (props: FormProps) => {
|
|||
<div className="btn-list">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rewrites_cancel"
|
||||
className="btn btn-secondary btn-standard"
|
||||
disabled={submitting || processingAdd}
|
||||
disabled={isSubmitting || processingAdd}
|
||||
onClick={() => {
|
||||
reset();
|
||||
toggleRewritesModal();
|
||||
|
@ -88,8 +125,9 @@ const Form = (props: FormProps) => {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="rewrites_save"
|
||||
className="btn btn-success btn-standard"
|
||||
disabled={submitting || pristine || processingAdd}>
|
||||
disabled={isSubmitting || !isDirty || processingAdd}>
|
||||
<Trans>save_btn</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -98,10 +136,4 @@ const Form = (props: FormProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default flow([
|
||||
withTranslation(),
|
||||
reduxForm({
|
||||
form: FORM_NAME.REWRITES,
|
||||
enableReinitialize: true,
|
||||
}),
|
||||
])(Form);
|
||||
export default Form;
|
||||
|
|
|
@ -14,7 +14,7 @@ interface ModalProps {
|
|||
processingAdd: boolean;
|
||||
processingDelete: boolean;
|
||||
modalType: string;
|
||||
currentRewrite?: object;
|
||||
currentRewrite?: { answer: string, domain: string; };
|
||||
}
|
||||
|
||||
const Modal = (props: ModalProps) => {
|
||||
|
@ -23,7 +23,6 @@ const Modal = (props: ModalProps) => {
|
|||
handleSubmit,
|
||||
toggleRewritesModal,
|
||||
processingAdd,
|
||||
processingDelete,
|
||||
modalType,
|
||||
currentRewrite,
|
||||
} = props;
|
||||
|
@ -50,11 +49,10 @@ const Modal = (props: ModalProps) => {
|
|||
</div>
|
||||
|
||||
<Form
|
||||
initialValues={{ ...currentRewrite }}
|
||||
onSubmit={handleSubmit}
|
||||
toggleRewritesModal={toggleRewritesModal}
|
||||
processingAdd={processingAdd}
|
||||
processingDelete={processingDelete}
|
||||
currentRewrite={currentRewrite}
|
||||
/>
|
||||
</div>
|
||||
</ReactModal>
|
||||
|
|
|
@ -1,38 +1,55 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { toggleAllServices } from '../../../helpers/helpers';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { renderServiceField } from '../../../helpers/form';
|
||||
import { FORM_NAME } from '../../../helpers/constants';
|
||||
import { ServiceField } from './ServiceField';
|
||||
|
||||
export type BlockedService = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_svg: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
blocked_services: Record<string, boolean>;
|
||||
};
|
||||
|
||||
interface FormProps {
|
||||
blockedServices: unknown[];
|
||||
pristine: boolean;
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
change: (...args: unknown[]) => unknown;
|
||||
submitting: boolean;
|
||||
initialValues: Record<string, boolean>;
|
||||
blockedServices: BlockedService[];
|
||||
onSubmit: (values: FormValues) => void;
|
||||
processing: boolean;
|
||||
processingSet: boolean;
|
||||
t: (...args: unknown[]) => string;
|
||||
}
|
||||
|
||||
const Form = (props: FormProps) => {
|
||||
const { blockedServices, handleSubmit, change, pristine, submitting, processing, processingSet } = props;
|
||||
export const Form = ({ initialValues, blockedServices, processing, processingSet, onSubmit }: FormProps) => {
|
||||
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 (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="form__group">
|
||||
<div className="row mb-4">
|
||||
<div className="col-6">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="blocked_services_block_all"
|
||||
className="btn btn-secondary btn-block"
|
||||
disabled={processing || processingSet}
|
||||
onClick={() => toggleAllServices(blockedServices, change, true)}>
|
||||
onClick={() => handleToggleAllServices(true)}>
|
||||
<Trans>block_all</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -40,24 +57,30 @@ const Form = (props: FormProps) => {
|
|||
<div className="col-6">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="blocked_services_unblock_all"
|
||||
className="btn btn-secondary btn-block"
|
||||
disabled={processing || processingSet}
|
||||
onClick={() => toggleAllServices(blockedServices, change, false)}>
|
||||
onClick={() => handleToggleAllServices(false)}>
|
||||
<Trans>unblock_all</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="services">
|
||||
{blockedServices.map((service: any) => (
|
||||
<Field
|
||||
{blockedServices.map((service: BlockedService) => (
|
||||
<Controller
|
||||
key={service.id}
|
||||
icon={service.icon_svg}
|
||||
name={`blocked_services.${service.id}`}
|
||||
type="checkbox"
|
||||
component={renderServiceField}
|
||||
placeholder={service.name}
|
||||
disabled={processing || processingSet}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ServiceField
|
||||
{...field}
|
||||
data-testid={`blocked_services_${service.id}`}
|
||||
placeholder={service.name}
|
||||
disabled={processing || processingSet}
|
||||
icon={service.icon_svg}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -66,19 +89,12 @@ const Form = (props: FormProps) => {
|
|||
<div className="btn-list">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="blocked_services_save"
|
||||
className="btn btn-success btn-standard btn-large"
|
||||
disabled={submitting || pristine || processing || processingSet}>
|
||||
disabled={isSubmitting || processing || processingSet}>
|
||||
<Trans>save_btn</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default flow([
|
||||
withTranslation(),
|
||||
reduxForm({
|
||||
form: FORM_NAME.SERVICES,
|
||||
enableReinitialize: true,
|
||||
}),
|
||||
])(Form);
|
||||
|
|
42
client/src/components/Filters/Services/ServiceField.tsx
Normal file
42
client/src/components/Filters/Services/ServiceField.tsx
Normal 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';
|
|
@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import Form from './Form';
|
||||
import { Form } from './Form';
|
||||
|
||||
import Card from '../../ui/Card';
|
||||
import { getBlockedServices, getAllBlockedServices, updateBlockedServices } from '../../../actions/services';
|
||||
|
@ -86,7 +86,8 @@ const Services = () => {
|
|||
<Card
|
||||
title={t('schedule_services')}
|
||||
subtitle={t('schedule_services_desc')}
|
||||
bodyType="card-body box-body--settings">
|
||||
bodyType="card-body box-body--settings"
|
||||
>
|
||||
<ScheduleForm schedule={services.list.schedule} onScheduleSubmit={handleScheduleSubmit} />
|
||||
</Card>
|
||||
</>
|
||||
|
|
|
@ -1,158 +1,71 @@
|
|||
import React, { useEffect } from 'react';
|
||||
|
||||
import { Field, type InjectedFormProps, reduxForm } from 'redux-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
DEBOUNCE_FILTER_TIMEOUT,
|
||||
DEFAULT_LOGS_FILTER,
|
||||
FORM_NAME,
|
||||
RESPONSE_FILTER,
|
||||
RESPONSE_FILTER_QUERIES,
|
||||
} from '../../../helpers/constants';
|
||||
import { setLogsFilter } from '../../../actions/queryLogs';
|
||||
import useDebounce from '../../../helpers/useDebounce';
|
||||
|
||||
import { createOnBlurHandler, getLogsUrlParams } from '../../../helpers/helpers';
|
||||
import { getLogsUrlParams } from '../../../helpers/helpers';
|
||||
|
||||
import Tooltip from '../../ui/Tooltip';
|
||||
import { RootState } from '../../../initialState';
|
||||
import { SearchField } from './SearchField';
|
||||
import { SearchFormValues } from '..';
|
||||
|
||||
interface renderFilterFieldProps {
|
||||
input: {
|
||||
value: string;
|
||||
};
|
||||
id: string;
|
||||
onClearInputClick: (...args: unknown[]) => unknown;
|
||||
type Props = {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
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>}
|
||||
</>
|
||||
);
|
||||
setIsLoading: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const FORM_NAMES = {
|
||||
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;
|
||||
|
||||
export const Form = ({ className, setIsLoading }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const { response_status, search } = useSelector(
|
||||
(state: RootState) => state?.form[FORM_NAME.LOGS_FILTER].values,
|
||||
shallowEqual,
|
||||
);
|
||||
const { register, watch, setValue } = useFormContext<SearchFormValues>();
|
||||
|
||||
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(() => {
|
||||
dispatch(
|
||||
setLogsFilter({
|
||||
response_status,
|
||||
response_status: responseStatusValue,
|
||||
search: debouncedSearch,
|
||||
}),
|
||||
);
|
||||
|
||||
history.replace(`${getLogsUrlParams(debouncedSearch, response_status)}`);
|
||||
}, [response_status, debouncedSearch]);
|
||||
history.replace(`${getLogsUrlParams(debouncedSearch, responseStatusValue)}`);
|
||||
}, [responseStatusValue, debouncedSearch]);
|
||||
|
||||
if (response_status && !(response_status in RESPONSE_FILTER_QUERIES)) {
|
||||
change(FORM_NAMES.response_status, DEFAULT_LOGS_FILTER[FORM_NAMES.response_status]);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (responseStatusValue && !(responseStatusValue in RESPONSE_FILTER_QUERIES)) {
|
||||
setValue('response_status', DEFAULT_LOGS_FILTER.response_status);
|
||||
}
|
||||
}, [responseStatusValue, setValue]);
|
||||
|
||||
const onInputClear = async () => {
|
||||
setIsLoading(true);
|
||||
change(FORM_NAMES.search, DEFAULT_LOGS_FILTER[FORM_NAMES.search]);
|
||||
setValue('search', DEFAULT_LOGS_FILTER.search);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const onEnterPress = (e: any) => {
|
||||
const onEnterPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
setDebouncedSearch(search);
|
||||
setDebouncedSearch(searchValue);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeOnBlur = (data: any) => data.trim();
|
||||
|
||||
return (
|
||||
<form
|
||||
className="d-flex flex-wrap form-control--container"
|
||||
|
@ -160,40 +73,28 @@ const Form = (props: FiltersFormProps & InjectedFormProps) => {
|
|||
e.preventDefault();
|
||||
}}>
|
||||
<div className="field__search">
|
||||
<Field
|
||||
id={FORM_NAMES.search}
|
||||
name={FORM_NAMES.search}
|
||||
component={renderFilterField}
|
||||
type="text"
|
||||
className={classNames('form-control form-control--search form-control--transparent', className)}
|
||||
<SearchField
|
||||
value={searchValue}
|
||||
handleChange={(val) => setValue('search', val)}
|
||||
onKeyDown={onEnterPress}
|
||||
onClear={onInputClear}
|
||||
placeholder={t('domain_or_client')}
|
||||
tooltip={t('query_log_strict_search')}
|
||||
onClearInputClick={onInputClear}
|
||||
onKeyDown={onEnterPress}
|
||||
normalizeOnBlur={normalizeOnBlur}
|
||||
className={classNames('form-control form-control--search form-control--transparent', className)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field__select">
|
||||
<Field
|
||||
name={FORM_NAMES.response_status}
|
||||
component="select"
|
||||
className={classNames(
|
||||
'form-control custom-select custom-select--logs custom-select__arrow--left form-control--transparent',
|
||||
responseStatusClass,
|
||||
)}>
|
||||
<select
|
||||
{...register('response_status')}
|
||||
className="form-control custom-select custom-select--logs custom-select__arrow--left form-control--transparent d-sm-block">
|
||||
{Object.values(RESPONSE_FILTER).map(({ QUERY, LABEL, disabled }: any) => (
|
||||
<option key={LABEL} value={QUERY} disabled={disabled}>
|
||||
{t(LABEL)}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const FiltersForm = reduxForm<Record<string, any>, FiltersFormProps>({
|
||||
form: FORM_NAME.LOGS_FILTER,
|
||||
enableReinitialize: true,
|
||||
})(Form);
|
||||
|
|
62
client/src/components/Logs/Filters/SearchField.tsx
Normal file
62
client/src/components/Logs/Filters/SearchField.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -2,17 +2,16 @@ import React from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { FiltersForm } from './Form';
|
||||
import { Form } from './Form';
|
||||
import { refreshFilteredLogs } from '../../../actions/queryLogs';
|
||||
import { addSuccessToast } from '../../../actions/toasts';
|
||||
|
||||
interface FiltersProps {
|
||||
filter: object;
|
||||
processingGetLogs: boolean;
|
||||
setIsLoading: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
const Filters = ({ filter, setIsLoading }: FiltersProps) => {
|
||||
const Filters = ({ setIsLoading }: FiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
@ -38,7 +37,9 @@ const Filters = ({ filter, setIsLoading }: FiltersProps) => {
|
|||
</svg>
|
||||
</button>
|
||||
</h1>
|
||||
<FiltersForm responseStatusClass="d-sm-block" setIsLoading={setIsLoading} initialValues={filter} />
|
||||
<Form
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -18,6 +18,7 @@ interface InfiniteTableProps {
|
|||
isLoading: boolean;
|
||||
items: unknown[];
|
||||
isSmallScreen: boolean;
|
||||
currentQuery: string;
|
||||
setDetailedDataCurrent: Dispatch<SetStateAction<any>>;
|
||||
setButtonType: (...args: unknown[]) => unknown;
|
||||
setModalOpened: (...args: unknown[]) => unknown;
|
||||
|
@ -27,6 +28,7 @@ const InfiniteTable = ({
|
|||
isLoading,
|
||||
items,
|
||||
isSmallScreen,
|
||||
currentQuery,
|
||||
setDetailedDataCurrent,
|
||||
setButtonType,
|
||||
setModalOpened,
|
||||
|
@ -43,7 +45,7 @@ const InfiniteTable = ({
|
|||
|
||||
const listener = useCallback(() => {
|
||||
if (!loadingRef.current && loader.current && isScrolledIntoView(loader.current)) {
|
||||
dispatch(getLogs());
|
||||
dispatch(getLogs(currentQuery));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -7,7 +7,8 @@ import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
import queryString from 'query-string';
|
||||
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';
|
||||
|
||||
|
@ -29,7 +30,12 @@ import { BUTTON_PREFIX } from './Cells/helpers';
|
|||
import AnonymizerNotification from './AnonymizerNotification';
|
||||
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]) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
|
@ -76,7 +82,6 @@ const Logs = () => {
|
|||
const {
|
||||
enabled,
|
||||
processingGetConfig,
|
||||
// processingAdditionalLogs,
|
||||
processingGetLogs,
|
||||
anonymize_client_ip: anonymizeClientIp,
|
||||
} = useSelector((state: RootState) => state.queryLogs, shallowEqual);
|
||||
|
@ -88,6 +93,17 @@ const Logs = () => {
|
|||
const search = search_url_param || filter?.search || '';
|
||||
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 [detailedDataCurrent, setDetailedDataCurrent] = useState({});
|
||||
const [buttonType, setButtonType] = useState(BLOCK_ACTIONS.BLOCK);
|
||||
|
@ -174,15 +190,12 @@ const Logs = () => {
|
|||
|
||||
const renderPage = () => (
|
||||
<>
|
||||
<Filters
|
||||
filter={{
|
||||
response_status,
|
||||
search,
|
||||
}}
|
||||
setIsLoading={setIsLoading}
|
||||
processingGetLogs={processingGetLogs}
|
||||
// processingAdditionalLogs={processingAdditionalLogs}
|
||||
/>
|
||||
<FormProvider {...formMethods}>
|
||||
<Filters
|
||||
setIsLoading={setIsLoading}
|
||||
processingGetLogs={processingGetLogs}
|
||||
/>
|
||||
</FormProvider>
|
||||
|
||||
<InfiniteTable
|
||||
isLoading={isLoading}
|
||||
|
@ -191,6 +204,7 @@ const Logs = () => {
|
|||
setDetailedDataCurrent={setDetailedDataCurrent}
|
||||
setButtonType={setButtonType}
|
||||
setModalOpened={setModalOpened}
|
||||
currentQuery={currentQuery}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
|
|
@ -111,6 +111,12 @@ const ClientsTable = ({
|
|||
config.tags = [];
|
||||
}
|
||||
|
||||
if (values.ids) {
|
||||
config.ids = values.ids.map((id) => id.name);
|
||||
} else {
|
||||
config.ids = [];
|
||||
}
|
||||
|
||||
if (typeof values.upstreams_cache_size === 'string') {
|
||||
config.upstreams_cache_size = 0;
|
||||
}
|
||||
|
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
export { BlockedServices } from './BlockedServices';
|
||||
export { ClientIds } from './ClientIds';
|
||||
export { ScheduleServices } from './ScheduleServices';
|
||||
export { MainSettings } from './MainSettings';
|
||||
export { UpstreamDns } from './UpstreamDns';
|
223
client/src/components/Settings/Clients/Form/index.tsx
Normal file
223
client/src/components/Settings/Clients/Form/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
28
client/src/components/Settings/Clients/Form/types.ts
Normal file
28
client/src/components/Settings/Clients/Form/types.ts
Normal 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[];
|
||||
};
|
|
@ -4,8 +4,15 @@ import { Trans, withTranslation } from 'react-i18next';
|
|||
import ReactModal from 'react-modal';
|
||||
|
||||
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) => {
|
||||
if (initial && initial.blocked_services) {
|
||||
|
@ -19,6 +26,7 @@ const getInitialData = ({ initial, modalType, clientId, clientName }: any) => {
|
|||
return {
|
||||
...initial,
|
||||
blocked_services: blocked,
|
||||
ids: normalizeIds(initial.ids),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -26,11 +34,14 @@ const getInitialData = ({ initial, modalType, clientId, clientName }: any) => {
|
|||
return {
|
||||
...initial,
|
||||
name: clientName,
|
||||
ids: [clientId],
|
||||
ids: [{ name: clientId }],
|
||||
};
|
||||
}
|
||||
|
||||
return initial;
|
||||
return {
|
||||
...initial,
|
||||
ids: normalizeIds(initial.ids),
|
||||
};
|
||||
};
|
||||
|
||||
interface ModalProps {
|
||||
|
@ -41,7 +52,7 @@ interface ModalProps {
|
|||
handleClose: (...args: unknown[]) => unknown;
|
||||
processingAdding: boolean;
|
||||
processingUpdating: boolean;
|
||||
tagsOptions: unknown[];
|
||||
tagsOptions: { label: string; value: string }[];
|
||||
t: (...args: unknown[]) => string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
@ -85,7 +96,7 @@ const Modal = ({
|
|||
<Form
|
||||
initialValues={{ ...initialData }}
|
||||
onSubmit={handleSubmit}
|
||||
handleClose={handleClose}
|
||||
onClose={handleClose}
|
||||
processingAdding={processingAdding}
|
||||
processingUpdating={processingUpdating}
|
||||
tagsOptions={tagsOptions}
|
||||
|
|
|
@ -1,28 +1,22 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { renderInputField, toNumber } from '../../../helpers/form';
|
||||
import { FORM_NAME, UINT32_RANGE } from '../../../helpers/constants';
|
||||
import { UINT32_RANGE } from '../../../helpers/constants';
|
||||
import {
|
||||
validateIpv4,
|
||||
validateRequiredValue,
|
||||
validateIpv4RangeEnd,
|
||||
validateGatewaySubnetMask,
|
||||
validateIpForGatewaySubnetMask,
|
||||
validateIpv4,
|
||||
validateIpv4RangeEnd,
|
||||
validateNotInRange,
|
||||
validateRequiredValue,
|
||||
} from '../../../helpers/validators';
|
||||
import { RootState } from '../../../initialState';
|
||||
import { DhcpFormValues } from '.';
|
||||
import { Input } from '../../ui/Controls/Input';
|
||||
import { toNumber } from '../../../helpers/form';
|
||||
|
||||
interface FormDHCPv4Props {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
submitting: boolean;
|
||||
initialValues: { v4?: any };
|
||||
type FormDHCPv4Props = {
|
||||
processingConfig?: boolean;
|
||||
change: (field: string, value: any) => void;
|
||||
reset: () => void;
|
||||
ipv4placeholders?: {
|
||||
gateway_ip: string;
|
||||
subnet_mask: string;
|
||||
|
@ -30,127 +24,179 @@ interface FormDHCPv4Props {
|
|||
range_end: 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 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 interface_name = interfaces?.values?.interface_name;
|
||||
const interfaceName = watch('interface_name');
|
||||
const isInterfaceIncludesIpv4 = interfaces?.[interfaceName]?.ipv4_addresses;
|
||||
|
||||
const isInterfaceIncludesIpv4 = useSelector(
|
||||
(state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv4_addresses,
|
||||
);
|
||||
const formValues = watch('v4');
|
||||
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 invalid =
|
||||
dhcp?.syncErrors ||
|
||||
interfaces?.syncErrors ||
|
||||
!isInterfaceIncludesIpv4 ||
|
||||
isEmptyConfig ||
|
||||
submitting ||
|
||||
processingConfig;
|
||||
|
||||
const validateRequired = useCallback(
|
||||
(value) => {
|
||||
if (isEmptyConfig) {
|
||||
return undefined;
|
||||
}
|
||||
return validateRequiredValue(value);
|
||||
},
|
||||
[isEmptyConfig],
|
||||
);
|
||||
const isDisabled = useMemo(() => {
|
||||
return isSubmitting || hasV4Errors || processingConfig || !isInterfaceIncludesIpv4 || isEmptyConfig;
|
||||
}, [isSubmitting, hasV4Errors, processingConfig, isInterfaceIncludesIpv4, isEmptyConfig]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="row">
|
||||
<div className="col-lg-6">
|
||||
<div className="form__group form__group--settings">
|
||||
<label>{t('dhcp_form_gateway_input')}</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="v4.gateway_ip"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t(ipv4placeholders.gateway_ip)}
|
||||
validate={[validateIpv4, validateRequired, validateNotInRange]}
|
||||
disabled={!isInterfaceIncludesIpv4}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
ipv4: validateIpv4,
|
||||
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
|
||||
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 className="form__group form__group--settings">
|
||||
<label>{t('dhcp_form_subnet_input')}</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="v4.subnet_mask"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t(ipv4placeholders.subnet_mask)}
|
||||
validate={[validateRequired, validateGatewaySubnetMask]}
|
||||
disabled={!isInterfaceIncludesIpv4}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
|
||||
subnet: validateGatewaySubnetMask,
|
||||
},
|
||||
}}
|
||||
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 className="col-lg-6">
|
||||
<div className="form__group form__group--settings">
|
||||
<div className="form__group mb-0">
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<label>{t('dhcp_form_range_title')}</label>
|
||||
</div>
|
||||
|
||||
<div className="col">
|
||||
<Field
|
||||
<Controller
|
||||
name="v4.range_start"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t(ipv4placeholders.range_start)}
|
||||
validate={[validateIpv4, validateIpForGatewaySubnetMask]}
|
||||
disabled={!isInterfaceIncludesIpv4}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
ipv4: validateIpv4,
|
||||
gateway: validateIpForGatewaySubnetMask,
|
||||
},
|
||||
}}
|
||||
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 className="col">
|
||||
<Field
|
||||
<Controller
|
||||
name="v4.range_end"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t(ipv4placeholders.range_end)}
|
||||
validate={[validateIpv4, validateIpv4RangeEnd, validateIpForGatewaySubnetMask]}
|
||||
disabled={!isInterfaceIncludesIpv4}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
ipv4: validateIpv4,
|
||||
rangeEnd: validateIpv4RangeEnd,
|
||||
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 className="form__group form__group--settings">
|
||||
<label>{t('dhcp_form_lease_title')}</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="v4.lease_duration"
|
||||
component={renderInputField}
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder={t(ipv4placeholders.lease_duration)}
|
||||
validate={validateRequired}
|
||||
normalize={toNumber}
|
||||
min={1}
|
||||
max={UINT32_RANGE.MAX}
|
||||
disabled={!isInterfaceIncludesIpv4}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...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 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')}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -158,9 +204,4 @@ const FormDHCPv4 = ({ handleSubmit, submitting, processingConfig, ipv4placeholde
|
|||
);
|
||||
};
|
||||
|
||||
export default reduxForm<
|
||||
Record<string, any>,
|
||||
Omit<FormDHCPv4Props, 'submitting' | 'handleSubmit' | 'reset' | 'change'>
|
||||
>({
|
||||
form: FORM_NAME.DHCPv4,
|
||||
})(FormDHCPv4);
|
||||
export default FormDHCPv4;
|
||||
|
|
|
@ -1,93 +1,92 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { renderInputField, toNumber } from '../../../helpers/form';
|
||||
import { FORM_NAME, UINT32_RANGE } from '../../../helpers/constants';
|
||||
import { UINT32_RANGE } from '../../../helpers/constants';
|
||||
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 {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
submitting: boolean;
|
||||
initialValues: {
|
||||
v6?: any;
|
||||
};
|
||||
change: (field: string, value: any) => void;
|
||||
reset: () => void;
|
||||
type FormDHCPv6Props = {
|
||||
processingConfig?: boolean;
|
||||
ipv6placeholders?: {
|
||||
range_start: string;
|
||||
range_end: 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 {
|
||||
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 interface_name = interfaces?.values?.interface_name;
|
||||
const formValues = watch('v6');
|
||||
const isEmptyConfig = !Object.values(formValues || {}).some(Boolean);
|
||||
|
||||
const isInterfaceIncludesIpv6 = useSelector(
|
||||
(state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv6_addresses,
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
const isDisabled = useMemo(() => {
|
||||
return isSubmitting || !isValid || processingConfig || !isInterfaceIncludesIpv6 || isEmptyConfig;
|
||||
}, [isSubmitting, isValid, processingConfig, isInterfaceIncludesIpv6, isEmptyConfig]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="row">
|
||||
<div className="col-lg-6">
|
||||
<div className="form__group form__group--settings">
|
||||
<div className="form__group mb-0">
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<label>{t('dhcp_form_range_title')}</label>
|
||||
</div>
|
||||
|
||||
<div className="col">
|
||||
<Field
|
||||
<Controller
|
||||
name="v6.range_start"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t(ipv6placeholders.range_start)}
|
||||
validate={[validateIpv6, validateRequired]}
|
||||
disabled={!isInterfaceIncludesIpv6}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: isInterfaceIncludesIpv6
|
||||
? {
|
||||
ipv6: validateIpv6,
|
||||
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 className="col">
|
||||
<Field
|
||||
<Controller
|
||||
name="v6.range_end"
|
||||
component="input"
|
||||
type="text"
|
||||
className="form-control disabled cursor--not-allowed"
|
||||
placeholder={t(ipv6placeholders.range_end)}
|
||||
value={t(ipv6placeholders.range_end)}
|
||||
disabled
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
data-testid="v6_range_end"
|
||||
placeholder={t(ipv6placeholders.range_end)}
|
||||
error={fieldState.error?.message}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -97,25 +96,43 @@ const FormDHCPv6 = ({ handleSubmit, submitting, processingConfig, ipv6placeholde
|
|||
|
||||
<div className="row">
|
||||
<div className="col-lg-6 form__group form__group--settings">
|
||||
<label>{t('dhcp_form_lease_title')}</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="v6.lease_duration"
|
||||
component={renderInputField}
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder={t(ipv6placeholders.lease_duration)}
|
||||
validate={validateRequired}
|
||||
normalizeOnBlur={toNumber}
|
||||
min={1}
|
||||
max={UINT32_RANGE.MAX}
|
||||
disabled={!isInterfaceIncludesIpv6}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: isInterfaceIncludesIpv6
|
||||
? {
|
||||
required: validateRequiredValue,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
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 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')}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -123,9 +140,4 @@ const FormDHCPv6 = ({ handleSubmit, submitting, processingConfig, ipv6placeholde
|
|||
);
|
||||
};
|
||||
|
||||
export default reduxForm<
|
||||
Record<string, any>,
|
||||
Omit<FormDHCPv6Props, 'handleSubmit' | 'change' | 'submitting' | 'reset'>
|
||||
>({
|
||||
form: FORM_NAME.DHCPv6,
|
||||
})(FormDHCPv6);
|
||||
export default FormDHCPv6;
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import React from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { renderSelectField } from '../../../helpers/form';
|
||||
import { validateRequiredValue } from '../../../helpers/validators';
|
||||
import { FORM_NAME } from '../../../helpers/constants';
|
||||
import { RootState } from '../../../initialState';
|
||||
import { DhcpFormValues } from '.';
|
||||
|
||||
const renderInterfaces = (interfaces: any) =>
|
||||
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;
|
||||
hardware_address: 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">
|
||||
<ul className="list-unstyled m-0">
|
||||
{getInterfaceValues({
|
||||
|
@ -77,11 +75,15 @@ const renderInterfaceValues = ({ gateway_ip, hardware_address, ip_addresses }: r
|
|||
|
||||
const Interfaces = () => {
|
||||
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 =
|
||||
useSelector((store: RootState) => store.form[FORM_NAME.DHCP_INTERFACES]?.values?.interface_name);
|
||||
const interface_name = watch('interface_name');
|
||||
|
||||
if (processingInterfaces || !interfaces) {
|
||||
return null;
|
||||
|
@ -92,27 +94,34 @@ const Interfaces = () => {
|
|||
return (
|
||||
<div className="row dhcp__interfaces">
|
||||
<div className="col col__dhcp">
|
||||
<Field
|
||||
name="interface_name"
|
||||
component={renderSelectField}
|
||||
<label htmlFor="interface_name" className="form__label">
|
||||
{t('dhcp_interface_select')}
|
||||
</label>
|
||||
<select
|
||||
id="interface_name"
|
||||
data-testid="interface_name"
|
||||
className="form-control custom-select pl-4 col-md"
|
||||
validate={[validateRequiredValue]}
|
||||
label="dhcp_interface_select">
|
||||
disabled={enabled}
|
||||
{...register('interface_name', {
|
||||
validate: validateRequiredValue,
|
||||
})}>
|
||||
<option value="" disabled={enabled}>
|
||||
{t('dhcp_interface_select')}
|
||||
</option>
|
||||
{renderInterfaces(interfaces)}
|
||||
</Field>
|
||||
</select>
|
||||
{errors.interface_name && (
|
||||
<div className="form__message form__message--error">{t(errors.interface_name.message)}</div>
|
||||
)}
|
||||
</div>
|
||||
{interfaceValue && renderInterfaceValues({
|
||||
gateway_ip: interfaceValue.gateway_ip,
|
||||
hardware_address: interfaceValue.hardware_address,
|
||||
ip_addresses: interfaceValue.ip_addresses
|
||||
})}
|
||||
{interfaceValue &&
|
||||
renderInterfaceValues({
|
||||
gateway_ip: interfaceValue.gateway_ip,
|
||||
hardware_address: interfaceValue.hardware_address,
|
||||
ip_addresses: interfaceValue.ip_addresses,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default reduxForm({
|
||||
form: FORM_NAME.DHCP_INTERFACES,
|
||||
})(Interfaces);
|
||||
export default Interfaces;
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
|
||||
|
||||
import { renderInputField, normalizeMac } from '../../../../helpers/form';
|
||||
import { normalizeMac } from '../../../../helpers/form';
|
||||
import {
|
||||
validateIpv4,
|
||||
validateMac,
|
||||
|
@ -12,12 +11,12 @@ import {
|
|||
validateIpv4InCidr,
|
||||
validateIpGateway,
|
||||
} from '../../../../helpers/validators';
|
||||
import { FORM_NAME } from '../../../../helpers/constants';
|
||||
|
||||
import { toggleLeaseModal } from '../../../../actions';
|
||||
import { RootState } from '../../../../initialState';
|
||||
import { Input } from '../../../ui/Controls/Input';
|
||||
|
||||
interface FormStaticLeaseProps {
|
||||
type Props = {
|
||||
initialValues?: {
|
||||
mac?: string;
|
||||
ip?: string;
|
||||
|
@ -25,63 +24,91 @@ interface FormStaticLeaseProps {
|
|||
cidr?: string;
|
||||
gatewayIp?: string;
|
||||
};
|
||||
pristine: boolean;
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
reset: () => void;
|
||||
submitting: boolean;
|
||||
processingAdding?: boolean;
|
||||
cidr?: string;
|
||||
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 dispatch = useDispatch();
|
||||
|
||||
const dynamicLease = useSelector((store: RootState) => store.dhcp.leaseModalConfig, shallowEqual);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting, isDirty },
|
||||
} = useForm({
|
||||
defaultValues: initialValues,
|
||||
mode: 'onBlur',
|
||||
});
|
||||
|
||||
const onClick = () => {
|
||||
reset();
|
||||
dispatch(toggleLeaseModal());
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="modal-body">
|
||||
<div className="form__group">
|
||||
<Field
|
||||
id="mac"
|
||||
<Controller
|
||||
name="mac"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_mac')}
|
||||
normalize={normalizeMac}
|
||||
validate={[validateRequiredValue, validateMac]}
|
||||
disabled={isEdit}
|
||||
control={control}
|
||||
rules={{ validate: { required: validateRequiredValue, mac: validateMac } }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
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 className="form__group">
|
||||
<Field
|
||||
id="ip"
|
||||
<Controller
|
||||
name="ip"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_subnet_ip', { cidr })}
|
||||
validate={[validateRequiredValue, validateIpv4, validateIpv4InCidr, validateIpGateway]}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
required: validateRequiredValue,
|
||||
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 className="form__group">
|
||||
<Field
|
||||
id="hostname"
|
||||
<Controller
|
||||
name="hostname"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_hostname')}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
data-testid="static_lease_hostname"
|
||||
error={fieldState.error?.message}
|
||||
placeholder={t('form_enter_hostname')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -90,16 +117,18 @@ const Form = ({ handleSubmit, reset, pristine, submitting, processingAdding, cid
|
|||
<div className="btn-list">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="static_lease_cancel"
|
||||
className="btn btn-secondary btn-standard"
|
||||
disabled={submitting}
|
||||
disabled={isSubmitting}
|
||||
onClick={onClick}>
|
||||
<Trans>cancel_btn</Trans>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="static_lease_save"
|
||||
className="btn btn-success btn-standard"
|
||||
disabled={submitting || processingAdding || (pristine && !dynamicLease)}>
|
||||
disabled={isSubmitting || processingAdding || (!isDirty && !dynamicLease)}>
|
||||
<Trans>save_btn</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -107,8 +136,3 @@ const Form = ({ handleSubmit, reset, pristine, submitting, processingAdding, cid
|
|||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default reduxForm<
|
||||
Record<string, any>,
|
||||
Omit<FormStaticLeaseProps, 'submitting' | 'handleSubmit' | 'reset' | 'pristine'>
|
||||
>({ form: FORM_NAME.LEASE })(Form);
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Trans, withTranslation } from 'react-i18next';
|
|||
import ReactModal from 'react-modal';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import Form from './Form';
|
||||
import { Form } from './Form';
|
||||
|
||||
import { toggleLeaseModal } from '../../../../actions';
|
||||
import { MODAL_TYPE } from '../../../../helpers/constants';
|
||||
|
|
|
@ -4,8 +4,8 @@ import { Trans, useTranslation } from 'react-i18next';
|
|||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { destroy } from 'redux-form';
|
||||
import { DHCP_DESCRIPTION_PLACEHOLDERS, DHCP_FORM_NAMES, STATUS_RESPONSE, FORM_NAME } from '../../../helpers/constants';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { DHCP_DESCRIPTION_PLACEHOLDERS, STATUS_RESPONSE } from '../../../helpers/constants';
|
||||
|
||||
import Leases from './Leases';
|
||||
|
||||
|
@ -40,6 +40,55 @@ import {
|
|||
import './index.css';
|
||||
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 { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
@ -65,12 +114,21 @@ const Dhcp = () => {
|
|||
modalType,
|
||||
} = useSelector((state: RootState) => state.dhcp, shallowEqual);
|
||||
|
||||
const interface_name =
|
||||
useSelector((state: RootState) => state.form[FORM_NAME.DHCP_INTERFACES]?.values?.interface_name);
|
||||
const isInterfaceIncludesIpv4 =
|
||||
useSelector((state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv4_addresses);
|
||||
const methods = useForm<DhcpFormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
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 [ipv6placeholders, setIpv6Placeholders] = useState(DHCP_DESCRIPTION_PLACEHOLDERS.ipv6);
|
||||
|
@ -85,6 +143,22 @@ const Dhcp = () => {
|
|||
}
|
||||
}, [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(() => {
|
||||
const [ipv4] = interfaces?.[interface_name]?.ipv4_addresses ?? [];
|
||||
const [ipv6] = interfaces?.[interface_name]?.ipv6_addresses ?? [];
|
||||
|
@ -103,13 +177,17 @@ const Dhcp = () => {
|
|||
const clear = () => {
|
||||
// eslint-disable-next-line no-alert
|
||||
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(getDhcpStatus());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (values: any) => {
|
||||
const handleSubmit = (values: DhcpFormValues) => {
|
||||
dispatch(
|
||||
setDhcpConfig({
|
||||
interface_name,
|
||||
|
@ -130,12 +208,7 @@ const Dhcp = () => {
|
|||
const enteredSomeValue = enteredSomeV4Value || enteredSomeV6Value || interfaceName;
|
||||
|
||||
const getToggleDhcpButton = () => {
|
||||
const filledConfig =
|
||||
interface_name &&
|
||||
(Object.values(v4)
|
||||
|
||||
.every(Boolean) ||
|
||||
Object.values(v6).every(Boolean));
|
||||
const filledConfig = interface_name && (Object.values(v4).every(Boolean) || Object.values(v6).every(Boolean));
|
||||
|
||||
const className = classNames('btn btn-sm', {
|
||||
'btn-gray': enabled,
|
||||
|
@ -173,9 +246,6 @@ const Dhcp = () => {
|
|||
|
||||
const toggleModal = () => dispatch(toggleLeaseModal());
|
||||
|
||||
const initialV4 = enteredSomeV4Value ? v4 : {};
|
||||
const initialV6 = enteredSomeV6Value ? v6 : {};
|
||||
|
||||
if (processing || processingInterfaces) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
@ -196,19 +266,13 @@ const Dhcp = () => {
|
|||
|
||||
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(
|
||||
dhcp?.syncErrors ||
|
||||
!isInterfaceIncludesIpv4 ||
|
||||
isEmptyConfig ||
|
||||
processingConfig ||
|
||||
!inputtedIPv4values,
|
||||
!isInterfaceIncludesIpv4 || isEmptyConfig || processingConfig || !inputtedIPv4values,
|
||||
);
|
||||
const cidr = inputtedIPv4values
|
||||
? `${dhcp?.values?.v4?.gateway_ip}/${subnetMaskToBitMask(dhcp?.values?.v4?.subnet_mask)}`
|
||||
: '';
|
||||
const cidr = inputtedIPv4values ? `${ipv4Config.gateway_ip}/${subnetMaskToBitMask(ipv4Config.subnet_mask)}` : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -246,29 +310,30 @@ const Dhcp = () => {
|
|||
</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 && (
|
||||
<Card title={t('dhcp_leases')} bodyType="card-body box-body--settings">
|
||||
<div className="row">
|
||||
|
@ -290,7 +355,7 @@ const Dhcp = () => {
|
|||
processingDeleting={processingDeleting}
|
||||
processingUpdating={processingUpdating}
|
||||
cidr={cidr}
|
||||
gatewayIp={dhcp?.values?.v4?.gateway_ip}
|
||||
gatewayIp={ipv4Config.gateway_ip}
|
||||
/>
|
||||
|
||||
<div className="btn-list mt-2">
|
||||
|
|
|
@ -1,118 +1,140 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Field, reduxForm, formValueSelector } from 'redux-form';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
import i18next from 'i18next';
|
||||
import { CLIENT_ID_LINK } from '../../../../helpers/constants';
|
||||
import { removeEmptyLines, trimMultilineString } from '../../../../helpers/helpers';
|
||||
import { Textarea } from '../../../ui/Controls/Textarea';
|
||||
|
||||
import { renderTextareaField } from '../../../../helpers/form';
|
||||
import { trimMultilineString, removeEmptyLines } from '../../../../helpers/helpers';
|
||||
import { CLIENT_ID_LINK, FORM_NAME } from '../../../../helpers/constants';
|
||||
type FormData = {
|
||||
allowed_clients: string;
|
||||
disallowed_clients: string;
|
||||
blocked_hosts: string;
|
||||
};
|
||||
|
||||
const fields = [
|
||||
const fields: {
|
||||
id: keyof FormData;
|
||||
title: string;
|
||||
subtitle: ReactNode;
|
||||
normalizeOnBlur: (value: string) => string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'allowed_clients',
|
||||
title: 'access_allowed_title',
|
||||
subtitle: 'access_allowed_desc',
|
||||
title: i18next.t('access_allowed_title'),
|
||||
subtitle: (
|
||||
<Trans
|
||||
components={{
|
||||
a: <a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer" />,
|
||||
}}>
|
||||
access_allowed_desc
|
||||
</Trans>
|
||||
),
|
||||
normalizeOnBlur: removeEmptyLines,
|
||||
},
|
||||
{
|
||||
id: 'disallowed_clients',
|
||||
title: 'access_disallowed_title',
|
||||
subtitle: 'access_disallowed_desc',
|
||||
title: i18next.t('access_disallowed_title'),
|
||||
subtitle: (
|
||||
<Trans
|
||||
components={{
|
||||
a: <a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer" />,
|
||||
}}>
|
||||
access_disallowed_desc
|
||||
</Trans>
|
||||
),
|
||||
normalizeOnBlur: trimMultilineString,
|
||||
},
|
||||
{
|
||||
id: 'blocked_hosts',
|
||||
title: 'access_blocked_title',
|
||||
subtitle: 'access_blocked_desc',
|
||||
title: i18next.t('access_blocked_title'),
|
||||
subtitle: i18next.t('access_blocked_desc'),
|
||||
normalizeOnBlur: removeEmptyLines,
|
||||
},
|
||||
];
|
||||
|
||||
interface FormProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
submitting: boolean;
|
||||
invalid: boolean;
|
||||
initialValues: object;
|
||||
type FormProps = {
|
||||
initialValues?: {
|
||||
allowed_clients?: string;
|
||||
disallowed_clients?: string;
|
||||
blocked_hosts?: string;
|
||||
};
|
||||
onSubmit: (data: FormData) => void;
|
||||
processingSet: boolean;
|
||||
t: (...args: unknown[]) => string;
|
||||
textarea?: boolean;
|
||||
allowedClients?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface renderFieldProps {
|
||||
id?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
disabled?: boolean;
|
||||
processingSet?: boolean;
|
||||
normalizeOnBlur?: (...args: unknown[]) => unknown;
|
||||
}
|
||||
const Form = ({ initialValues, onSubmit, processingSet }: FormProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let Form = (props: FormProps) => {
|
||||
const { allowedClients, handleSubmit, submitting, invalid, processingSet } = props;
|
||||
const {
|
||||
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 = ({
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
disabled = false,
|
||||
processingSet,
|
||||
normalizeOnBlur,
|
||||
}: renderFieldProps) => (
|
||||
<div key={id} className="form__group mb-5">
|
||||
<label className="form__label form__label--with-desc" htmlFor={id}>
|
||||
<Trans>{title}</Trans>
|
||||
}: {
|
||||
id: keyof FormData;
|
||||
title: string;
|
||||
subtitle: ReactNode;
|
||||
normalizeOnBlur: (value: string) => string;
|
||||
}) => {
|
||||
const disabled = allowedClients && id === 'disallowed_clients';
|
||||
|
||||
{disabled && (
|
||||
<>
|
||||
<span> </span>(<Trans>disabled</Trans>)
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
return (
|
||||
<div key={id} className="form__group mb-5">
|
||||
<label className="form__label form__label--with-desc" htmlFor={id}>
|
||||
{title}
|
||||
{disabled && <> ({t('disabled')})</>}
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans
|
||||
components={{
|
||||
a: (
|
||||
<a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer">
|
||||
text
|
||||
</a>
|
||||
),
|
||||
}}>
|
||||
{subtitle}
|
||||
</Trans>
|
||||
<div className="form__desc form__desc--top">{subtitle}</div>
|
||||
|
||||
<Controller
|
||||
name={id}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={id}
|
||||
data-testid={id}
|
||||
disabled={disabled || processingSet}
|
||||
onBlur={(e) => {
|
||||
field.onChange(normalizeOnBlur(e.target.value));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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 (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{fields.map((f) => {
|
||||
return renderField({
|
||||
...f,
|
||||
disabled: allowedClients && f.id === 'disallowed_clients' || false
|
||||
});
|
||||
})}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.map((f) => renderField(f))}
|
||||
|
||||
<div className="card-actions">
|
||||
<div className="btn-list">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="access_save"
|
||||
className="btn btn-success btn-standard"
|
||||
disabled={submitting || invalid || processingSet}>
|
||||
<Trans>save_config</Trans>
|
||||
disabled={isSubmitting || !isDirty || processingSet}>
|
||||
{t('save_config')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -120,18 +142,4 @@ let Form = (props: FormProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
const selector = formValueSelector(FORM_NAME.ACCESS);
|
||||
|
||||
Form = connect((state) => {
|
||||
const allowedClients = selector(state, 'allowed_clients');
|
||||
return {
|
||||
allowedClients,
|
||||
};
|
||||
})(Form);
|
||||
|
||||
export default flow([
|
||||
withTranslation(),
|
||||
reduxForm({
|
||||
form: FORM_NAME.ACCESS,
|
||||
}),
|
||||
])(Form);
|
||||
export default Form;
|
||||
|
|
|
@ -1,52 +1,72 @@
|
|||
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 { 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 i18next from 'i18next';
|
||||
import { clearDnsCache } from '../../../../actions/dnsConfig';
|
||||
import { CACHE_CONFIG_FIELDS, UINT32_RANGE } from '../../../../helpers/constants';
|
||||
import { replaceZeroWithEmptyString } from '../../../../helpers/helpers';
|
||||
import { RootState } from '../../../../initialState';
|
||||
import { Checkbox } from '../../../ui/Controls/Checkbox';
|
||||
|
||||
const INPUTS_FIELDS = [
|
||||
{
|
||||
name: CACHE_CONFIG_FIELDS.cache_size,
|
||||
title: 'cache_size',
|
||||
description: 'cache_size_desc',
|
||||
placeholder: 'enter_cache_size',
|
||||
title: i18next.t('cache_size'),
|
||||
description: i18next.t('cache_size_desc'),
|
||||
placeholder: i18next.t('enter_cache_size'),
|
||||
},
|
||||
{
|
||||
name: CACHE_CONFIG_FIELDS.cache_ttl_min,
|
||||
title: 'cache_ttl_min_override',
|
||||
description: 'cache_ttl_min_override_desc',
|
||||
placeholder: 'enter_cache_ttl_min_override',
|
||||
title: i18next.t('cache_ttl_min_override'),
|
||||
description: i18next.t('cache_ttl_min_override_desc'),
|
||||
placeholder: i18next.t('enter_cache_ttl_min_override'),
|
||||
},
|
||||
{
|
||||
name: CACHE_CONFIG_FIELDS.cache_ttl_max,
|
||||
title: 'cache_ttl_max_override',
|
||||
description: 'cache_ttl_max_override_desc',
|
||||
placeholder: 'enter_cache_ttl_max_override',
|
||||
title: i18next.t('cache_ttl_max_override'),
|
||||
description: i18next.t('cache_ttl_max_override_desc'),
|
||||
placeholder: i18next.t('enter_cache_ttl_max_override'),
|
||||
},
|
||||
];
|
||||
|
||||
interface CacheFormProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
submitting: boolean;
|
||||
invalid: boolean;
|
||||
}
|
||||
type FormData = {
|
||||
cache_size: number;
|
||||
cache_ttl_min: number;
|
||||
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 dispatch = useDispatch();
|
||||
|
||||
const { processingSetConfig } = useSelector((state: RootState) => state.dnsConfig, shallowEqual);
|
||||
const { cache_ttl_max, cache_ttl_min } = useSelector(
|
||||
(state: RootState) => state.form[FORM_NAME.CACHE].values,
|
||||
shallowEqual,
|
||||
);
|
||||
const { processingSetConfig } = useSelector((state: RootState) => state.dnsConfig);
|
||||
|
||||
const {
|
||||
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;
|
||||
|
||||
|
@ -57,29 +77,30 @@ const Form = ({ handleSubmit, submitting, invalid }: CacheFormProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="row">
|
||||
{INPUTS_FIELDS.map(({ name, title, description, placeholder }) => (
|
||||
<div className="col-12" key={name}>
|
||||
<div className="col-12 col-md-7 p-0">
|
||||
<div className="form__group form__group--settings">
|
||||
<label htmlFor={name} className="form__label form__label--with-desc">
|
||||
{t(title)}
|
||||
{title}
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">{t(description)}</div>
|
||||
<div className="form__desc form__desc--top">{description}</div>
|
||||
|
||||
<Field
|
||||
name={name}
|
||||
<input
|
||||
type="number"
|
||||
component={renderInputField}
|
||||
placeholder={t(placeholder)}
|
||||
disabled={processingSetConfig}
|
||||
data-testid={`dns_${name}`}
|
||||
className="form-control"
|
||||
normalizeOnBlur={replaceZeroWithEmptyString}
|
||||
normalize={toNumber}
|
||||
placeholder={placeholder}
|
||||
disabled={processingSetConfig}
|
||||
min={0}
|
||||
max={UINT32_RANGE.MAX}
|
||||
{...register(name as keyof FormData, {
|
||||
valueAsNumber: true,
|
||||
setValueAs: (value) => replaceZeroWithEmptyString(value),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -91,13 +112,18 @@ const Form = ({ handleSubmit, submitting, invalid }: CacheFormProps) => {
|
|||
<div className="row">
|
||||
<div className="col-12 col-md-7">
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name="cache_optimistic"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('cache_optimistic')}
|
||||
disabled={processingSetConfig}
|
||||
subtitle={t('cache_optimistic_desc')}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
data-testid="dns_cache_optimistic"
|
||||
title={t('cache_optimistic')}
|
||||
subtitle={t('cache_optimistic_desc')}
|
||||
disabled={processingSetConfig}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -105,19 +131,21 @@ const Form = ({ handleSubmit, submitting, invalid }: CacheFormProps) => {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="dns_save"
|
||||
className="btn btn-success btn-standard btn-large"
|
||||
disabled={submitting || invalid || processingSetConfig || minExceedsMax}>
|
||||
<Trans>save_btn</Trans>
|
||||
disabled={isSubmitting || !isDirty || processingSetConfig || minExceedsMax}>
|
||||
{t('save_btn')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dns_clear"
|
||||
className="btn btn-outline-secondary btn-standard form__button"
|
||||
onClick={handleClearCache}>
|
||||
<Trans>clear_cache</Trans>
|
||||
{t('clear_cache')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default reduxForm({ form: FORM_NAME.CACHE })(Form);
|
||||
export default Form;
|
||||
|
|
|
@ -1,211 +1,279 @@
|
|||
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 { Trans, useTranslation } from 'react-i18next';
|
||||
import {
|
||||
renderInputField,
|
||||
renderRadioField,
|
||||
renderTextareaField,
|
||||
CheckboxField,
|
||||
toNumber,
|
||||
} from '../../../../helpers/form';
|
||||
import {
|
||||
validateIpv4,
|
||||
validateIpv6,
|
||||
validateRequiredValue,
|
||||
validateIp,
|
||||
validateIPv4Subnet,
|
||||
validateIPv6Subnet,
|
||||
} from '../../../../helpers/validators';
|
||||
import i18next from 'i18next';
|
||||
import { validateIp, validateIpv4, validateIpv6, validateRequiredValue } from '../../../../helpers/validators';
|
||||
|
||||
import { removeEmptyLines } from '../../../../helpers/helpers';
|
||||
import { BLOCKING_MODES, FORM_NAME, UINT32_RANGE } from '../../../../helpers/constants';
|
||||
import { RootState } from '../../../../initialState';
|
||||
import { BLOCKING_MODES, UINT32_RANGE } from '../../../../helpers/constants';
|
||||
import { Checkbox } from '../../../ui/Controls/Checkbox';
|
||||
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',
|
||||
placeholder: 'dnssec_enable',
|
||||
subtitle: 'dnssec_enable_desc',
|
||||
placeholder: i18next.t('dnssec_enable'),
|
||||
subtitle: i18next.t('dnssec_enable_desc'),
|
||||
},
|
||||
{
|
||||
name: 'disable_ipv6',
|
||||
placeholder: 'disable_ipv6',
|
||||
subtitle: 'disable_ipv6_desc',
|
||||
placeholder: i18next.t('disable_ipv6'),
|
||||
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',
|
||||
label: i18next.t('blocking_ipv4'),
|
||||
description: i18next.t('blocking_ipv4_desc'),
|
||||
validateIp: validateIpv4,
|
||||
},
|
||||
{
|
||||
description: 'blocking_ipv6_desc',
|
||||
name: 'blocking_ipv6',
|
||||
label: i18next.t('blocking_ipv6'),
|
||||
description: i18next.t('blocking_ipv6_desc'),
|
||||
validateIp: validateIpv6,
|
||||
},
|
||||
];
|
||||
|
||||
const getFields = (processing: any, t: any) =>
|
||||
Object.values(BLOCKING_MODES)
|
||||
const blockingModeOptions = [
|
||||
{
|
||||
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) => (
|
||||
<Field
|
||||
key={mode}
|
||||
name="blocking_mode"
|
||||
type="radio"
|
||||
component={renderRadioField}
|
||||
value={mode}
|
||||
placeholder={t(mode)}
|
||||
disabled={processing}
|
||||
/>
|
||||
));
|
||||
const blockingModeDescriptions = [
|
||||
i18next.t(`blocking_mode_default`),
|
||||
i18next.t(`blocking_mode_refused`),
|
||||
i18next.t(`blocking_mode_nxdomain`),
|
||||
i18next.t(`blocking_mode_null_ip`),
|
||||
i18next.t(`blocking_mode_custom_ip`),
|
||||
];
|
||||
|
||||
interface ConfigFormProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
submitting: boolean;
|
||||
invalid: boolean;
|
||||
type FormData = {
|
||||
ratelimit: number;
|
||||
ratelimit_subnet_len_ipv4: number;
|
||||
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;
|
||||
}
|
||||
initialValues?: Partial<FormData>;
|
||||
onSubmit: (data: FormData) => void;
|
||||
};
|
||||
|
||||
const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps) => {
|
||||
const Form = ({ processing, initialValues, onSubmit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { blocking_mode, edns_cs_enabled, edns_cs_use_custom } = useSelector(
|
||||
(state: RootState) => state.form[FORM_NAME.BLOCKING_MODE].values ?? {},
|
||||
shallowEqual,
|
||||
);
|
||||
|
||||
const {
|
||||
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 (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="row">
|
||||
<div className="col-12 col-md-7">
|
||||
<div className="form__group form__group--settings">
|
||||
<label htmlFor="ratelimit" className="form__label form__label--with-desc">
|
||||
<Trans>rate_limit</Trans>
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>rate_limit_desc</Trans>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="ratelimit"
|
||||
type="number"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_rate_limit')}
|
||||
normalize={toNumber}
|
||||
validate={validateRequiredValue}
|
||||
min={UINT32_RANGE.MIN}
|
||||
max={UINT32_RANGE.MAX}
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
data-testid="dns_config_ratelimit"
|
||||
type="number"
|
||||
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 className="col-12 col-md-7">
|
||||
<div className="form__group form__group--settings">
|
||||
<label htmlFor="ratelimit_subnet_len_ipv4" className="form__label form__label--with-desc">
|
||||
<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
|
||||
<Controller
|
||||
name="ratelimit_subnet_len_ipv4"
|
||||
type="number"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_rate_limit_subnet_len')}
|
||||
normalize={toNumber}
|
||||
validate={[validateRequiredValue, validateIPv4Subnet]}
|
||||
min={0}
|
||||
max={32}
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
data-testid="dns_config_subnet_ipv4"
|
||||
type="number"
|
||||
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 className="col-12 col-md-7">
|
||||
<div className="form__group form__group--settings">
|
||||
<label htmlFor="ratelimit_subnet_len_ipv6" className="form__label form__label--with-desc">
|
||||
<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
|
||||
<Controller
|
||||
name="ratelimit_subnet_len_ipv6"
|
||||
type="number"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_rate_limit_subnet_len')}
|
||||
normalize={toNumber}
|
||||
validate={[validateRequiredValue, validateIPv6Subnet]}
|
||||
min={0}
|
||||
max={128}
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
data-testid="dns_config_subnet_ipv6"
|
||||
type="number"
|
||||
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 className="col-12 col-md-7">
|
||||
<div className="form__group form__group--settings">
|
||||
<label htmlFor="ratelimit_whitelist" className="form__label form__label--with-desc">
|
||||
<Trans>rate_limit_whitelist</Trans>
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>rate_limit_whitelist_desc</Trans>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="ratelimit_whitelist"
|
||||
component={renderTextareaField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('rate_limit_whitelist_placeholder')}
|
||||
normalizeOnBlur={removeEmptyLines}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
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 className="col-12">
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name="edns_cs_enabled"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('edns_enable')}
|
||||
disabled={processing}
|
||||
subtitle={t('edns_cs_desc')}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
data-testid="dns_config_edns_cs_enabled"
|
||||
title={t('edns_enable')}
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 form__group form__group--inner">
|
||||
<div className="form__group ">
|
||||
<Field
|
||||
<div className="form__group">
|
||||
<Controller
|
||||
name="edns_cs_use_custom"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('edns_use_custom_ip')}
|
||||
disabled={processing || !edns_cs_enabled}
|
||||
subtitle={t('edns_use_custom_ip_desc')}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
data-testid="dns_config_edns_use_custom_ip"
|
||||
title={t('edns_use_custom_ip')}
|
||||
disabled={processing || !edns_cs_enabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{edns_cs_use_custom && (
|
||||
<Field
|
||||
<Controller
|
||||
name="edns_cs_custom_ip"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_ip')}
|
||||
validate={[validateIp, validateRequiredValue]}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
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>
|
||||
|
@ -213,13 +281,18 @@ const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps
|
|||
{checkboxes.map(({ name, placeholder, subtitle }) => (
|
||||
<div className="col-12" key={name}>
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name={name}
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t(placeholder)}
|
||||
disabled={processing}
|
||||
subtitle={t(subtitle)}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
data-testid={`dns_config_${name}`}
|
||||
title={placeholder}
|
||||
subtitle={subtitle}
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -227,42 +300,50 @@ const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps
|
|||
|
||||
<div className="col-12">
|
||||
<div className="form__group form__group--settings mb-4">
|
||||
<label className="form__label form__label--with-desc">
|
||||
<Trans>blocking_mode</Trans>
|
||||
</label>
|
||||
<label className="form__label form__label--with-desc">{t('blocking_mode')}</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
{Object.values(BLOCKING_MODES)
|
||||
|
||||
.map((mode: any) => (
|
||||
<li key={mode}>
|
||||
<Trans>{`blocking_mode_${mode}`}</Trans>
|
||||
</li>
|
||||
))}
|
||||
{blockingModeDescriptions.map((desc: string) => (
|
||||
<li key={desc}>{desc}</li>
|
||||
))}
|
||||
</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>
|
||||
{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="form__group form__group--settings">
|
||||
<label className="form__label form__label--with-desc" htmlFor={name}>
|
||||
<Trans>{name}</Trans>
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>{description}</Trans>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name={name}
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_ip')}
|
||||
validate={[validateIp, validateRequiredValue]}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
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>
|
||||
|
@ -272,24 +353,27 @@ const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps
|
|||
|
||||
<div className="col-12 col-md-7">
|
||||
<div className="form__group form__group--settings">
|
||||
<label htmlFor="blocked_response_ttl" className="form__label form__label--with-desc">
|
||||
<Trans>blocked_response_ttl</Trans>
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>blocked_response_ttl_desc</Trans>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="blocked_response_ttl"
|
||||
type="number"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_blocked_response_ttl')}
|
||||
normalize={toNumber}
|
||||
validate={validateRequiredValue}
|
||||
min={UINT32_RANGE.MIN}
|
||||
max={UINT32_RANGE.MAX}
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
data-testid="dns_config_blocked_response_ttl"
|
||||
type="number"
|
||||
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>
|
||||
|
@ -297,14 +381,13 @@ const Form = ({ handleSubmit, submitting, invalid, processing }: ConfigFormProps
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="dns_config_save"
|
||||
className="btn btn-success btn-standard btn-large"
|
||||
disabled={submitting || invalid || processing}>
|
||||
<Trans>save_btn</Trans>
|
||||
disabled={isSubmitting || !isDirty || processing}>
|
||||
{t('save_btn')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default reduxForm<Record<string, any>, Omit<ConfigFormProps, 'invalid' | 'submitting' | 'handleSubmit'>>({
|
||||
form: FORM_NAME.BLOCKING_MODE,
|
||||
})(Form);
|
||||
export default Form;
|
||||
|
|
|
@ -1,185 +1,110 @@
|
|||
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 { Field, reduxForm } from 'redux-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
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 i18next from 'i18next';
|
||||
import clsx from 'clsx';
|
||||
import { testUpstreamWithFormValues } from '../../../../actions';
|
||||
|
||||
import { removeEmptyLines, trimLinesAndRemoveEmpty } from '../../../../helpers/helpers';
|
||||
|
||||
import { DNS_REQUEST_OPTIONS, UINT32_RANGE, UPSTREAM_CONFIGURATION_WIKI_LINK } from '../../../../helpers/constants';
|
||||
import { removeEmptyLines } from '../../../../helpers/helpers';
|
||||
import { getTextareaCommentsHighlight, syncScroll } from '../../../../helpers/highlightTextareaComments';
|
||||
import '../../../ui/texareaCommentsHighlight.css';
|
||||
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 { toNumber } from '../../../../helpers/form';
|
||||
|
||||
const UPSTREAM_DNS_NAME = 'upstream_dns';
|
||||
const UPSTREAM_MODE_NAME = 'upstream_mode';
|
||||
|
||||
interface renderFieldProps {
|
||||
name: string;
|
||||
component: any;
|
||||
type: string;
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
subtitle?: string;
|
||||
value?: string;
|
||||
normalizeOnBlur?: (...args: unknown[]) => unknown;
|
||||
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>
|
||||
);
|
||||
type FormData = {
|
||||
upstream_dns: string;
|
||||
upstream_mode: string;
|
||||
fallback_dns: string;
|
||||
bootstrap_dns: string;
|
||||
local_ptr_upstreams: string;
|
||||
use_private_ptr_resolvers: boolean;
|
||||
resolve_clients: boolean;
|
||||
upstream_timeout: number;
|
||||
};
|
||||
|
||||
interface renderTextareaWithHighlightFieldProps {
|
||||
className: string;
|
||||
disabled?: boolean;
|
||||
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)}
|
||||
</>
|
||||
);
|
||||
type FormProps = {
|
||||
initialValues?: Partial<FormData>;
|
||||
onSubmit: (data: FormData) => void;
|
||||
};
|
||||
|
||||
const INPUT_FIELDS = [
|
||||
const upstreamModeOptions = [
|
||||
{
|
||||
name: UPSTREAM_MODE_NAME,
|
||||
type: 'radio',
|
||||
label: i18next.t('load_balancing'),
|
||||
desc: <Trans components={{ br: <br />, b: <b /> }}>load_balancing_desc</Trans>,
|
||||
value: DNS_REQUEST_OPTIONS.LOAD_BALANCING,
|
||||
component: renderRadioField,
|
||||
subtitle: 'load_balancing_desc',
|
||||
placeholder: 'load_balancing',
|
||||
},
|
||||
{
|
||||
name: UPSTREAM_MODE_NAME,
|
||||
type: 'radio',
|
||||
label: i18next.t('parallel_requests'),
|
||||
desc: <Trans components={{ br: <br />, b: <b /> }}>upstream_parallel</Trans>,
|
||||
value: DNS_REQUEST_OPTIONS.PARALLEL,
|
||||
component: renderRadioField,
|
||||
subtitle: 'upstream_parallel',
|
||||
placeholder: 'parallel_requests',
|
||||
},
|
||||
{
|
||||
name: UPSTREAM_MODE_NAME,
|
||||
type: 'radio',
|
||||
label: i18next.t('fastest_addr'),
|
||||
desc: <Trans components={{ br: <br />, b: <b /> }}>fastest_addr_desc</Trans>,
|
||||
value: DNS_REQUEST_OPTIONS.FASTEST_ADDR,
|
||||
component: renderRadioField,
|
||||
subtitle: 'fastest_addr_desc',
|
||||
placeholder: 'fastest_addr',
|
||||
},
|
||||
];
|
||||
|
||||
interface 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 Form = ({ initialValues, onSubmit }: FormProps) => {
|
||||
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 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 handleUpstreamTest = () => dispatch(testUpstreamWithFormValues());
|
||||
|
||||
const testButtonClass = classnames('btn btn-primary btn-standard mr-2', {
|
||||
'btn-loading': processingTestUpstream,
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isSubmitting, isDirty },
|
||||
} = useForm<FormData>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
upstream_dns: initialValues?.upstream_dns || '',
|
||||
upstream_mode: initialValues?.upstream_mode || DNS_REQUEST_OPTIONS.LOAD_BALANCING,
|
||||
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 = {
|
||||
a: <a href={UPSTREAM_CONFIGURATION_WIKI_LINK} target="_blank" rel="noopener noreferrer" />,
|
||||
const upstream_dns = watch('upstream_dns');
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="form--upstream">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="form--upstream">
|
||||
<div className="row">
|
||||
<label className="col form__label" htmlFor={UPSTREAM_DNS_NAME}>
|
||||
<Trans components={components}>upstream_dns_help</Trans>{' '}
|
||||
<label className="col form__label" htmlFor="upstream_dns">
|
||||
<Trans
|
||||
components={{
|
||||
a: <a href={UPSTREAM_CONFIGURATION_WIKI_LINK} target="_blank" rel="noopener noreferrer" />,
|
||||
}}>
|
||||
upstream_dns_help
|
||||
</Trans>{' '}
|
||||
<Trans
|
||||
components={[
|
||||
<a
|
||||
|
@ -196,44 +121,69 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
|
|||
|
||||
<div className="col-12 mb-4">
|
||||
<div className="text-edit-container">
|
||||
<Field
|
||||
id={UPSTREAM_DNS_NAME}
|
||||
name={UPSTREAM_DNS_NAME}
|
||||
component={renderTextareaWithHighlightField}
|
||||
type="text"
|
||||
className="form-control form-control--textarea font-monospace text-input"
|
||||
placeholder={t('upstream_dns')}
|
||||
disabled={processingSetConfig || processingTestUpstream}
|
||||
normalizeOnBlur={removeEmptyLines}
|
||||
<Controller
|
||||
name="upstream_dns"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<Textarea
|
||||
{...field}
|
||||
id={UPSTREAM_DNS_NAME}
|
||||
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>
|
||||
{INPUT_FIELDS.map(renderField)}
|
||||
|
||||
<div className="col-12">
|
||||
<Examples />
|
||||
|
||||
<hr />
|
||||
</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">
|
||||
<label className="form__label form__label--with-desc" htmlFor="fallback_dns">
|
||||
<Trans>fallback_dns_title</Trans>
|
||||
{t('fallback_dns_title')}
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>fallback_dns_desc</Trans>
|
||||
</div>
|
||||
<div className="form__desc form__desc--top">{t('fallback_dns_desc')}</div>
|
||||
|
||||
<Field
|
||||
id="fallback_dns"
|
||||
<Controller
|
||||
name="fallback_dns"
|
||||
component={renderTextareaField}
|
||||
type="text"
|
||||
className="form-control form-control--textarea form-control--textarea-small font-monospace"
|
||||
placeholder={t('fallback_dns_placeholder')}
|
||||
disabled={processingSetConfig}
|
||||
normalizeOnBlur={removeEmptyLines}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id="fallback_dns"
|
||||
data-testid="fallback_dns"
|
||||
wrapperClassName="mb-0"
|
||||
placeholder={t('fallback_dns_placeholder')}
|
||||
disabled={processingSetConfig}
|
||||
trimOnBlur
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -241,24 +191,30 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
|
|||
<hr />
|
||||
</div>
|
||||
|
||||
<div className="col-12 mb-2">
|
||||
<div className="col-12">
|
||||
<label className="form__label form__label--with-desc" htmlFor="bootstrap_dns">
|
||||
<Trans>bootstrap_dns</Trans>
|
||||
{t('bootstrap_dns')}
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>bootstrap_dns_desc</Trans>
|
||||
</div>
|
||||
<div className="form__desc form__desc--top">{t('bootstrap_dns_desc')}</div>
|
||||
|
||||
<Field
|
||||
id="bootstrap_dns"
|
||||
<Controller
|
||||
name="bootstrap_dns"
|
||||
component={renderTextareaField}
|
||||
type="text"
|
||||
className="form-control form-control--textarea form-control--textarea-small font-monospace"
|
||||
placeholder={t('bootstrap_dns')}
|
||||
disabled={processingSetConfig}
|
||||
normalizeOnBlur={removeEmptyLines}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id="bootstrap_dns"
|
||||
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>
|
||||
|
||||
|
@ -268,43 +224,47 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
|
|||
|
||||
<div className="col-12">
|
||||
<label className="form__label form__label--with-desc" htmlFor="local_ptr">
|
||||
<Trans>local_ptr_title</Trans>
|
||||
{t('local_ptr_title')}
|
||||
</label>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>local_ptr_desc</Trans>
|
||||
</div>
|
||||
<div className="form__desc form__desc--top">{t('local_ptr_desc')}</div>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
{/** TODO: Add internazionalization for "" */}
|
||||
{defaultLocalPtrUpstreams?.length > 0 ? (
|
||||
<Trans values={{ ip: defaultLocalPtrUpstreams.map((s: any) => `"${s}"`).join(', ') }}>
|
||||
local_ptr_default_resolver
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>local_ptr_no_default_resolver</Trans>
|
||||
)}
|
||||
{defaultLocalPtrUpstreams?.length > 0
|
||||
? t('local_ptr_default_resolver', {
|
||||
ip: defaultLocalPtrUpstreams.map((s: any) => `"${s}"`).join(', '),
|
||||
})
|
||||
: t('local_ptr_no_default_resolver')}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="local_ptr_upstreams"
|
||||
<Controller
|
||||
name="local_ptr_upstreams"
|
||||
component={renderTextareaField}
|
||||
type="text"
|
||||
className="form-control form-control--textarea form-control--textarea-small font-monospace"
|
||||
placeholder={t('local_ptr_placeholder')}
|
||||
disabled={processingSetConfig}
|
||||
normalizeOnBlur={removeEmptyLines}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id="local_ptr_upstreams"
|
||||
data-testid="local_ptr_upstreams"
|
||||
placeholder={t('local_ptr_placeholder')}
|
||||
disabled={processingSetConfig}
|
||||
trimOnBlur
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Field
|
||||
<Controller
|
||||
name="use_private_ptr_resolvers"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('use_private_ptr_resolvers_title')}
|
||||
subtitle={t('use_private_ptr_resolvers_desc')}
|
||||
disabled={processingSetConfig}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
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>
|
||||
|
@ -313,14 +273,19 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
|
|||
<hr />
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<Field
|
||||
<div className="col-12 mb-4">
|
||||
<Controller
|
||||
name="resolve_clients"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('resolve_clients_title')}
|
||||
subtitle={t('resolve_clients_desc')}
|
||||
disabled={processingSetConfig}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
data-testid="dns_resolve_clients"
|
||||
title={t('resolve_clients_title')}
|
||||
subtitle={t('resolve_clients_desc')}
|
||||
disabled={processingSetConfig}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -338,16 +303,26 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
|
|||
<Trans>upstream_timeout_desc</Trans>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="upstream_timeout"
|
||||
type="number"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={t('form_enter_upstream_timeout')}
|
||||
normalize={toNumber}
|
||||
validate={validateRequiredValue}
|
||||
min={1}
|
||||
max={UINT32_RANGE.MAX}
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
id="upstream_timeout"
|
||||
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>
|
||||
|
@ -357,17 +332,21 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
|
|||
<div className="btn-list">
|
||||
<button
|
||||
type="button"
|
||||
className={testButtonClass}
|
||||
data-testid="dns_upstream_test"
|
||||
className={clsx('btn btn-primary btn-standard mr-2', {
|
||||
'btn-loading': processingTestUpstream,
|
||||
})}
|
||||
onClick={handleUpstreamTest}
|
||||
disabled={!upstream_dns || processingTestUpstream}>
|
||||
<Trans>test_upstream_btn</Trans>
|
||||
{t('test_upstream_btn')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="dns_upstream_save"
|
||||
className="btn btn-success btn-standard"
|
||||
disabled={submitting || invalid || processingSetConfig || processingTestUpstream}>
|
||||
<Trans>apply_btn</Trans>
|
||||
disabled={isSubmitting || !isDirty || processingSetConfig || processingTestUpstream}>
|
||||
{t('apply_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -375,4 +354,4 @@ const Form = ({ submitting, invalid, handleSubmit }: FormProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default reduxForm({ form: FORM_NAME.UPSTREAM })(Form);
|
||||
export default Form;
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Field, reduxForm, formValueSelector } from 'redux-form';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { renderInputField, CheckboxField, renderRadioField, toNumber } from '../../../helpers/form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import i18next from 'i18next';
|
||||
import {
|
||||
validateServerName,
|
||||
validateIsSafePort,
|
||||
|
@ -14,7 +12,6 @@ import {
|
|||
validatePortTLS,
|
||||
validatePlainDns,
|
||||
} from '../../../helpers/validators';
|
||||
import i18n from '../../../i18n';
|
||||
|
||||
import KeyStatus from './KeyStatus';
|
||||
|
||||
|
@ -22,51 +19,39 @@ import CertificateStatus from './CertificateStatus';
|
|||
import {
|
||||
DNS_OVER_QUIC_PORT,
|
||||
DNS_OVER_TLS_PORT,
|
||||
FORM_NAME,
|
||||
STANDARD_HTTPS_PORT,
|
||||
ENCRYPTION_SOURCE,
|
||||
} 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 errors: { port_dns_over_tls?: string; port_https?: string } = {};
|
||||
const certificateSourceOptions = [
|
||||
{
|
||||
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) {
|
||||
if (values.port_dns_over_tls === values.port_https) {
|
||||
errors.port_dns_over_tls = i18n.t('form_error_equal');
|
||||
const keySourceOptions = [
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
const validationMessage = (warningValidation: string, isWarning: boolean) => {
|
||||
if (!warningValidation) {
|
||||
return null;
|
||||
}
|
||||
|
@ -88,56 +73,60 @@ const validationMessage = (warningValidation: any, isWarning: any) => {
|
|||
);
|
||||
};
|
||||
|
||||
interface FormProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
handleChange?: (...args: unknown[]) => unknown;
|
||||
isEnabled: boolean;
|
||||
servePlainDns: boolean;
|
||||
certificateChain: string;
|
||||
privateKey: string;
|
||||
certificatePath: string;
|
||||
privateKeyPath: string;
|
||||
change: (...args: unknown[]) => unknown;
|
||||
submitting: boolean;
|
||||
invalid: boolean;
|
||||
initialValues: object;
|
||||
processingConfig: boolean;
|
||||
processingValidate: boolean;
|
||||
status_key?: string;
|
||||
not_after?: string;
|
||||
warning_validation?: string;
|
||||
valid_chain?: boolean;
|
||||
valid_key?: boolean;
|
||||
valid_cert?: boolean;
|
||||
valid_pair?: boolean;
|
||||
dns_names?: string[];
|
||||
key_type?: string;
|
||||
issuer?: string;
|
||||
subject?: string;
|
||||
t: (...args: unknown[]) => string;
|
||||
setTlsConfig: (...args: unknown[]) => unknown;
|
||||
validateTlsConfig: (...args: unknown[]) => unknown;
|
||||
certificateSource?: string;
|
||||
privateKeySource?: string;
|
||||
privateKeySaved?: boolean;
|
||||
}
|
||||
export type EncryptionFormValues = {
|
||||
enabled?: boolean;
|
||||
serve_plain_dns?: boolean;
|
||||
server_name?: string;
|
||||
force_https?: boolean;
|
||||
port_https?: number;
|
||||
port_dns_over_tls?: number;
|
||||
port_dns_over_quic?: number;
|
||||
certificate_chain?: string;
|
||||
private_key?: string;
|
||||
certificate_path?: string;
|
||||
private_key_path?: string;
|
||||
certificate_source?: string;
|
||||
key_source?: string;
|
||||
private_key_saved?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialValues: EncryptionFormValues;
|
||||
encryption: EncryptionData;
|
||||
onSubmit: (values: EncryptionFormValues) => void;
|
||||
debouncedConfigValidation: (values: EncryptionFormValues) => void;
|
||||
setTlsConfig: (values: Partial<EncryptionData>) => void;
|
||||
validateTlsConfig: (values: Partial<EncryptionData>) => void;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
enabled: false,
|
||||
serve_plain_dns: true,
|
||||
server_name: '',
|
||||
force_https: false,
|
||||
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 {
|
||||
t,
|
||||
handleSubmit,
|
||||
handleChange,
|
||||
isEnabled,
|
||||
servePlainDns,
|
||||
certificateChain,
|
||||
privateKey,
|
||||
certificatePath,
|
||||
privateKeyPath,
|
||||
change,
|
||||
invalid,
|
||||
submitting,
|
||||
processingConfig,
|
||||
processingValidate,
|
||||
not_after,
|
||||
valid_chain,
|
||||
valid_key,
|
||||
|
@ -148,37 +137,100 @@ let Form = (props: FormProps) => {
|
|||
issuer,
|
||||
subject,
|
||||
warning_validation,
|
||||
setTlsConfig,
|
||||
validateTlsConfig,
|
||||
certificateSource,
|
||||
privateKeySource,
|
||||
privateKeySaved,
|
||||
} = props;
|
||||
processingConfig,
|
||||
processingValidate,
|
||||
} = encryption;
|
||||
|
||||
const {
|
||||
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 processing = submitting || processingConfig || processingValidate;
|
||||
const processing = isSubmitting || processingConfig || processingValidate;
|
||||
|
||||
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 isWarning = valid_key && valid_cert && valid_pair;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<div className="form__group form__group--settings mb-3">
|
||||
<Field
|
||||
<Controller
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('encryption_enable')}
|
||||
onChange={handleChange}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox {...field} title={t('encryption_enable')} onBlur={handleBlur} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -187,13 +239,13 @@ let Form = (props: FormProps) => {
|
|||
</div>
|
||||
|
||||
<div className="form__group mb-3 mt-5">
|
||||
<Field
|
||||
<Controller
|
||||
name="serve_plain_dns"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('encryption_plain_dns_enable')}
|
||||
onChange={handleChange}
|
||||
validate={validatePlainDns}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => validatePlainDns(value, getValues()),
|
||||
}}
|
||||
render={({ field }) => <Checkbox {...field} title={t('encryption_plain_dns_enable')} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -212,16 +264,20 @@ let Form = (props: FormProps) => {
|
|||
|
||||
<div className="col-lg-6">
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
id="server_name"
|
||||
<Controller
|
||||
name="server_name"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('encryption_server_enter')}
|
||||
onChange={handleChange}
|
||||
disabled={!isEnabled}
|
||||
validate={validateServerName}
|
||||
control={control}
|
||||
rules={{ validate: validateServerName }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder={t('encryption_server_enter')}
|
||||
error={fieldState.error?.message}
|
||||
disabled={!isEnabled}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="form__desc">
|
||||
|
@ -232,13 +288,12 @@ let Form = (props: FormProps) => {
|
|||
|
||||
<div className="col-lg-6">
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name="force_https"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('encryption_redirect')}
|
||||
onChange={handleChange}
|
||||
disabled={!isEnabled}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox {...field} title={t('encryption_redirect')} disabled={!isEnabled} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="form__desc">
|
||||
|
@ -255,17 +310,24 @@ let Form = (props: FormProps) => {
|
|||
<Trans>encryption_https</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
id="port_https"
|
||||
<Controller
|
||||
name="port_https"
|
||||
component={renderInputField}
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder={t('encryption_https')}
|
||||
validate={[validatePort, validateIsSafePort]}
|
||||
normalize={toNumber}
|
||||
onChange={handleChange}
|
||||
disabled={!isEnabled}
|
||||
control={control}
|
||||
rules={{ validate: { validatePort, validateIsSafePort } }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
placeholder={t('encryption_https')}
|
||||
error={fieldState.error?.message}
|
||||
disabled={!isEnabled}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
field.onChange(toNumber(value));
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="form__desc">
|
||||
|
@ -280,17 +342,24 @@ let Form = (props: FormProps) => {
|
|||
<Trans>encryption_dot</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
id="port_dns_over_tls"
|
||||
<Controller
|
||||
name="port_dns_over_tls"
|
||||
component={renderInputField}
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder={t('encryption_dot')}
|
||||
validate={[validatePortTLS]}
|
||||
normalize={toNumber}
|
||||
onChange={handleChange}
|
||||
disabled={!isEnabled}
|
||||
control={control}
|
||||
rules={{ validate: validatePortTLS }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
placeholder={t('encryption_dot')}
|
||||
error={fieldState.error?.message}
|
||||
disabled={!isEnabled}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
field.onChange(toNumber(value));
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="form__desc">
|
||||
|
@ -305,17 +374,24 @@ let Form = (props: FormProps) => {
|
|||
<Trans>encryption_doq</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
id="port_dns_over_quic"
|
||||
<Controller
|
||||
name="port_dns_over_quic"
|
||||
component={renderInputField}
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder={t('encryption_doq')}
|
||||
validate={[validatePortQuic]}
|
||||
normalize={toNumber}
|
||||
onChange={handleChange}
|
||||
disabled={!isEnabled}
|
||||
control={control}
|
||||
rules={{ validate: validatePortQuic }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
placeholder={t('encryption_doq')}
|
||||
error={fieldState.error?.message}
|
||||
disabled={!isEnabled}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
field.onChange(toNumber(value));
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="form__desc">
|
||||
|
@ -352,50 +428,44 @@ let Form = (props: FormProps) => {
|
|||
|
||||
<div className="form__inline mb-2">
|
||||
<div className="custom-controls-stacked">
|
||||
<Field
|
||||
<Controller
|
||||
name="certificate_source"
|
||||
component={renderRadioField}
|
||||
type="radio"
|
||||
className="form-control mr-2"
|
||||
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}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Radio {...field} options={certificateSourceOptions} disabled={!isEnabled} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{certificateSource === ENCRYPTION_SOURCE.CONTENT && (
|
||||
<Field
|
||||
id="certificate_chain"
|
||||
{certificateSource === ENCRYPTION_SOURCE.CONTENT ? (
|
||||
<Controller
|
||||
name="certificate_chain"
|
||||
component="textarea"
|
||||
type="text"
|
||||
className="form-control form-control--textarea"
|
||||
placeholder={t('encryption_certificates_input')}
|
||||
onChange={handleChange}
|
||||
disabled={!isEnabled}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={t('encryption_certificates_input')}
|
||||
disabled={!isEnabled}
|
||||
error={fieldState.error?.message}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{certificateSource === ENCRYPTION_SOURCE.PATH && (
|
||||
<Field
|
||||
id="certificate_path"
|
||||
) : (
|
||||
<Controller
|
||||
name="certificate_path"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('encryption_certificate_path')}
|
||||
onChange={handleChange}
|
||||
disabled={!isEnabled}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder={t('encryption_certificate_path')}
|
||||
error={fieldState.error?.message}
|
||||
disabled={!isEnabled}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -424,70 +494,67 @@ let Form = (props: FormProps) => {
|
|||
|
||||
<div className="form__inline mb-2">
|
||||
<div className="custom-controls-stacked">
|
||||
<Field
|
||||
<Controller
|
||||
name="key_source"
|
||||
component={renderRadioField}
|
||||
type="radio"
|
||||
className="form-control mr-2"
|
||||
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}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Radio {...field} options={keySourceOptions} disabled={!isEnabled} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{privateKeySource === ENCRYPTION_SOURCE.PATH && (
|
||||
<Field
|
||||
{privateKeySource === ENCRYPTION_SOURCE.CONTENT ? (
|
||||
<>
|
||||
<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"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('encryption_private_key_path')}
|
||||
onChange={handleChange}
|
||||
disabled={!isEnabled}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
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 className="form__status">
|
||||
|
@ -505,44 +572,11 @@ let Form = (props: FormProps) => {
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-standart"
|
||||
disabled={submitting || processingConfig}
|
||||
onClick={() => clearFields(change, setTlsConfig, validateTlsConfig, t)}>
|
||||
disabled={isSubmitting || processingConfig}
|
||||
onClick={clearFields}>
|
||||
<Trans>reset_settings</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</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);
|
||||
|
|
|
@ -1,61 +1,60 @@
|
|||
import React, { Component } from 'react';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { debounce } from 'lodash';
|
||||
import { DEBOUNCE_TIMEOUT, ENCRYPTION_SOURCE } from '../../../helpers/constants';
|
||||
|
||||
import Form from './Form';
|
||||
|
||||
import { EncryptionFormValues, Form } from './Form';
|
||||
import Card from '../../ui/Card';
|
||||
|
||||
import PageTitle from '../../ui/PageTitle';
|
||||
|
||||
import Loading from '../../ui/Loading';
|
||||
import { EncryptionData } from '../../../initialState';
|
||||
|
||||
interface EncryptionProps {
|
||||
setTlsConfig: (...args: unknown[]) => unknown;
|
||||
validateTlsConfig: (...args: unknown[]) => unknown;
|
||||
type Props = {
|
||||
encryption: EncryptionData;
|
||||
t: (...args: unknown[]) => string;
|
||||
}
|
||||
setTlsConfig: (values: Partial<EncryptionData>) => void;
|
||||
validateTlsConfig: (values: Partial<EncryptionData>) => void;
|
||||
};
|
||||
|
||||
class Encryption extends Component<EncryptionProps> {
|
||||
componentDidMount() {
|
||||
const { validateTlsConfig, encryption } = this.props;
|
||||
export const Encryption = ({ encryption, setTlsConfig, validateTlsConfig }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (encryption.enabled) {
|
||||
validateTlsConfig(encryption);
|
||||
}
|
||||
}
|
||||
|
||||
handleFormSubmit = (values: any) => {
|
||||
const submitValues = this.getSubmitValues(values);
|
||||
|
||||
this.props.setTlsConfig(submitValues);
|
||||
};
|
||||
|
||||
handleFormChange = debounce((values) => {
|
||||
const submitValues = this.getSubmitValues(values);
|
||||
|
||||
if (submitValues.enabled) {
|
||||
this.props.validateTlsConfig(submitValues);
|
||||
}
|
||||
}, DEBOUNCE_TIMEOUT);
|
||||
|
||||
getInitialValues = (data: any) => {
|
||||
const { certificate_chain, private_key, private_key_saved } = data;
|
||||
const initialValues = useMemo((): EncryptionFormValues => {
|
||||
const {
|
||||
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,
|
||||
} = encryption;
|
||||
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;
|
||||
|
||||
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,
|
||||
key_source,
|
||||
};
|
||||
};
|
||||
}, [encryption]);
|
||||
|
||||
getSubmitValues = (values: any) => {
|
||||
const getSubmitValues = useCallback((values: any) => {
|
||||
const { certificate_source, key_source, private_key_saved, ...config } = values;
|
||||
|
||||
if (certificate_source === ENCRYPTION_SOURCE.PATH) {
|
||||
|
@ -76,63 +75,47 @@ class Encryption extends Component<EncryptionProps> {
|
|||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
const { encryption, t } = this.props;
|
||||
const {
|
||||
enabled,
|
||||
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,
|
||||
} = encryption;
|
||||
const handleFormSubmit = useCallback(
|
||||
(values: any) => {
|
||||
const submitValues = getSubmitValues(values);
|
||||
setTlsConfig(submitValues);
|
||||
},
|
||||
[getSubmitValues, setTlsConfig],
|
||||
);
|
||||
|
||||
const initialValues = this.getInitialValues({
|
||||
enabled,
|
||||
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,
|
||||
});
|
||||
const validateConfig = useCallback((values) => {
|
||||
const submitValues = getSubmitValues(values);
|
||||
|
||||
return (
|
||||
<div className="encryption">
|
||||
<PageTitle title={t('encryption_settings')} />
|
||||
if (submitValues.enabled) {
|
||||
validateTlsConfig(submitValues);
|
||||
}
|
||||
}, []);
|
||||
|
||||
{encryption.processing && <Loading />}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
}
|
||||
const debouncedConfigValidation = useMemo(() => debounce(validateConfig, DEBOUNCE_TIMEOUT), [validateConfig]);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
|
@ -1,39 +1,115 @@
|
|||
import React from 'react';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
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 {
|
||||
initialValues: object;
|
||||
processing: boolean;
|
||||
setFiltersConfig: (...args: unknown[]) => unknown;
|
||||
t: (...args: unknown[]) => string;
|
||||
}
|
||||
if (interval === THREE_DAYS_INTERVAL || interval === SEVEN_DAYS_INTERVAL) {
|
||||
return i18next.t('interval_days', { count: interval / DAY });
|
||||
}
|
||||
|
||||
const FiltersConfig = (props: FiltersConfigProps) => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
return i18next.t('interval_hours', { count: interval });
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,147 +1,182 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { change, Field, formValueSelector, reduxForm } from 'redux-form';
|
||||
import { connect } from 'react-redux';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
|
||||
import {
|
||||
CheckboxField,
|
||||
toFloatNumber,
|
||||
renderTextareaField,
|
||||
renderInputField,
|
||||
renderRadioField,
|
||||
} from '../../../helpers/form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import i18next from 'i18next';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { trimLinesAndRemoveEmpty } from '../../../helpers/helpers';
|
||||
import {
|
||||
FORM_NAME,
|
||||
QUERY_LOG_INTERVALS_DAYS,
|
||||
HOUR,
|
||||
DAY,
|
||||
RETENTION_CUSTOM,
|
||||
RETENTION_CUSTOM_INPUT,
|
||||
RETENTION_RANGE,
|
||||
CUSTOM_INTERVAL,
|
||||
} from '../../../helpers/constants';
|
||||
import { QUERY_LOG_INTERVALS_DAYS, HOUR, DAY, RETENTION_CUSTOM, RETENTION_RANGE } from '../../../helpers/constants';
|
||||
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) {
|
||||
case RETENTION_CUSTOM:
|
||||
return t('settings_custom');
|
||||
return i18next.t('settings_custom');
|
||||
case 6 * HOUR:
|
||||
return t('interval_6_hour');
|
||||
return i18next.t('interval_6_hour');
|
||||
case DAY:
|
||||
return t('interval_24_hour');
|
||||
return i18next.t('interval_24_hour');
|
||||
default:
|
||||
return t('interval_days', { count: interval / DAY });
|
||||
return i18next.t('interval_days', { count: interval / DAY });
|
||||
}
|
||||
};
|
||||
|
||||
const getIntervalFields = (processing: any, t: any, toNumber: any) =>
|
||||
QUERY_LOG_INTERVALS_DAYS.map((interval) => (
|
||||
<Field
|
||||
key={interval}
|
||||
name="interval"
|
||||
type="radio"
|
||||
component={renderRadioField}
|
||||
value={interval}
|
||||
placeholder={getIntervalTitle(interval, t)}
|
||||
normalize={toNumber}
|
||||
disabled={processing}
|
||||
/>
|
||||
));
|
||||
export type FormValues = {
|
||||
enabled: boolean;
|
||||
anonymize_client_ip: boolean;
|
||||
interval: number;
|
||||
customInterval?: number | null;
|
||||
ignored: string;
|
||||
};
|
||||
|
||||
interface FormProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
handleClear: (...args: unknown[]) => unknown;
|
||||
submitting: boolean;
|
||||
invalid: boolean;
|
||||
type Props = {
|
||||
initialValues: Partial<FormValues>;
|
||||
processing: boolean;
|
||||
processingClear: boolean;
|
||||
t: (...args: unknown[]) => string;
|
||||
interval?: number;
|
||||
customInterval?: number;
|
||||
dispatch: (...args: unknown[]) => unknown;
|
||||
}
|
||||
processingReset: boolean;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export const Form = ({ initialValues, processing, processingReset, onSubmit, onReset }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let Form = (props: FormProps) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
submitting,
|
||||
invalid,
|
||||
processing,
|
||||
processingClear,
|
||||
handleClear,
|
||||
t,
|
||||
interval,
|
||||
customInterval,
|
||||
dispatch,
|
||||
} = props;
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
enabled: initialValues.enabled || false,
|
||||
anonymize_client_ip: initialValues.anonymize_client_ip || false,
|
||||
interval: initialValues.interval || DAY,
|
||||
customInterval: initialValues.customInterval || null,
|
||||
ignored: initialValues.ignored || '',
|
||||
},
|
||||
});
|
||||
|
||||
const intervalValue = watch('interval');
|
||||
const customIntervalValue = watch('customInterval');
|
||||
|
||||
useEffect(() => {
|
||||
if (QUERY_LOG_INTERVALS_DAYS.includes(interval)) {
|
||||
dispatch(change(FORM_NAME.LOG_CONFIG, CUSTOM_INTERVAL, null));
|
||||
if (QUERY_LOG_INTERVALS_DAYS.includes(intervalValue)) {
|
||||
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 (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmitForm)}>
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('query_log_enable')}
|
||||
disabled={processing}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
data-testid="logs_enabled"
|
||||
title={t('query_log_enable')}
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name="anonymize_client_ip"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('anonymize_client_ip')}
|
||||
subtitle={t('anonymize_client_ip_desc')}
|
||||
disabled={processing}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
data-testid="logs_anonymize_client_ip"
|
||||
title={t('anonymize_client_ip')}
|
||||
subtitle={t('anonymize_client_ip_desc')}
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="form__label">
|
||||
<div className="form__label">
|
||||
<Trans>query_log_retention</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form__group form__group--settings">
|
||||
<div className="custom-controls-stacked">
|
||||
<Field
|
||||
key={RETENTION_CUSTOM}
|
||||
name="interval"
|
||||
type="radio"
|
||||
component={renderRadioField}
|
||||
value={QUERY_LOG_INTERVALS_DAYS.includes(interval) ? RETENTION_CUSTOM : interval}
|
||||
placeholder={getIntervalTitle(RETENTION_CUSTOM, t)}
|
||||
normalize={toFloatNumber}
|
||||
disabled={processing}
|
||||
/>
|
||||
{!QUERY_LOG_INTERVALS_DAYS.includes(interval) && (
|
||||
<label className="custom-control custom-radio">
|
||||
<input
|
||||
type="radio"
|
||||
data-testid="logs_config_interval"
|
||||
className="custom-control-input"
|
||||
disabled={processing}
|
||||
checked={!QUERY_LOG_INTERVALS_DAYS.includes(intervalValue)}
|
||||
value={RETENTION_CUSTOM}
|
||||
onChange={(e) => {
|
||||
setValue('interval', parseInt(e.target.value, 10));
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="custom-control-label">{getIntervalTitle(RETENTION_CUSTOM)}</span>
|
||||
</label>
|
||||
|
||||
{!QUERY_LOG_INTERVALS_DAYS.includes(intervalValue) && (
|
||||
<div className="form__group--input">
|
||||
<div className="form__desc form__desc--top">{t('custom_rotation_input')}</div>
|
||||
|
||||
<Field
|
||||
key={RETENTION_CUSTOM_INPUT}
|
||||
name={CUSTOM_INTERVAL}
|
||||
type="number"
|
||||
className="form-control"
|
||||
component={renderInputField}
|
||||
disabled={processing}
|
||||
normalize={toFloatNumber}
|
||||
min={RETENTION_RANGE.MIN}
|
||||
max={RETENTION_RANGE.MAX}
|
||||
<Controller
|
||||
name="customInterval"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
data-testid="logs_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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
|
@ -154,51 +189,41 @@ let Form = (props: FormProps) => {
|
|||
</div>
|
||||
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name="ignored"
|
||||
type="textarea"
|
||||
className="form-control form-control--textarea font-monospace text-input"
|
||||
component={renderTextareaField}
|
||||
placeholder={t('ignore_domains')}
|
||||
disabled={processing}
|
||||
normalizeOnBlur={trimLinesAndRemoveEmpty}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
data-testid="logs_config_ingored"
|
||||
placeholder={t('ignore_domains')}
|
||||
className="text-input"
|
||||
disabled={processing}
|
||||
error={fieldState.error?.message}
|
||||
onBlur={handleIgnoredBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="logs_config_save"
|
||||
className="btn btn-success btn-standard btn-large"
|
||||
disabled={
|
||||
submitting ||
|
||||
invalid ||
|
||||
processing ||
|
||||
(!QUERY_LOG_INTERVALS_DAYS.includes(interval) && !customInterval)
|
||||
}>
|
||||
disabled={disableSubmit}>
|
||||
<Trans>save_btn</Trans>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="logs_config_clear"
|
||||
className="btn btn-outline-secondary btn-standard form__button"
|
||||
onClick={() => handleClear()}
|
||||
disabled={processingClear}>
|
||||
onClick={onReset}
|
||||
disabled={processingReset}>
|
||||
<Trans>query_log_clear</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</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);
|
||||
|
|
|
@ -3,7 +3,7 @@ import { withTranslation } from 'react-i18next';
|
|||
|
||||
import Card from '../../ui/Card';
|
||||
|
||||
import Form from './Form';
|
||||
import { Form, FormValues } from './Form';
|
||||
import { HOUR } from '../../../helpers/constants';
|
||||
|
||||
interface LogsConfigProps {
|
||||
|
@ -20,7 +20,7 @@ interface LogsConfigProps {
|
|||
}
|
||||
|
||||
class LogsConfig extends Component<LogsConfigProps> {
|
||||
handleFormSubmit = (values: any) => {
|
||||
handleFormSubmit = (values: FormValues) => {
|
||||
const { t, interval: prevInterval } = this.props;
|
||||
const { interval, customInterval, ...rest } = values;
|
||||
|
||||
|
@ -53,19 +53,12 @@ class LogsConfig extends Component<LogsConfigProps> {
|
|||
render() {
|
||||
const {
|
||||
t,
|
||||
|
||||
enabled,
|
||||
|
||||
interval,
|
||||
|
||||
processing,
|
||||
|
||||
processingClear,
|
||||
|
||||
anonymize_client_ip,
|
||||
|
||||
ignored,
|
||||
|
||||
customInterval,
|
||||
} = this.props;
|
||||
|
||||
|
@ -80,10 +73,10 @@ class LogsConfig extends Component<LogsConfigProps> {
|
|||
anonymize_client_ip,
|
||||
ignored: ignored?.join('\n'),
|
||||
}}
|
||||
onSubmit={this.handleFormSubmit}
|
||||
processing={processing}
|
||||
processingClear={processingClear}
|
||||
handleClear={this.handleClear}
|
||||
processingReset={processingClear}
|
||||
onSubmit={this.handleFormSubmit}
|
||||
onReset={this.handleClear}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
@ -63,6 +63,7 @@
|
|||
}
|
||||
|
||||
.form__message {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
@ -97,6 +98,10 @@
|
|||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.form__label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form__label--bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
|
@ -1,90 +1,101 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { change, Field, formValueSelector, reduxForm } from 'redux-form';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
import { connect } from 'react-redux';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import i18next from 'i18next';
|
||||
|
||||
import {
|
||||
renderRadioField,
|
||||
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 { Controller, useForm } from 'react-hook-form';
|
||||
import { STATS_INTERVALS_DAYS, DAY, RETENTION_CUSTOM, RETENTION_RANGE } from '../../../helpers/constants';
|
||||
|
||||
import { trimLinesAndRemoveEmpty } from '../../../helpers/helpers';
|
||||
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) => {
|
||||
switch (intervalMs) {
|
||||
const getIntervalTitle = (interval: any) => {
|
||||
switch (interval) {
|
||||
case RETENTION_CUSTOM:
|
||||
return t('settings_custom');
|
||||
return i18next.t('settings_custom');
|
||||
case DAY:
|
||||
return t('interval_24_hour');
|
||||
return i18next.t('interval_24_hour');
|
||||
default:
|
||||
return t('interval_days', { count: intervalMs / DAY });
|
||||
return i18next.t('interval_days', { count: interval / DAY });
|
||||
}
|
||||
};
|
||||
|
||||
interface FormProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
handleReset: (...args: unknown[]) => string;
|
||||
change: (...args: unknown[]) => unknown;
|
||||
submitting: boolean;
|
||||
invalid: boolean;
|
||||
export type FormValues = {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
customInterval?: number | null;
|
||||
ignored: string;
|
||||
};
|
||||
|
||||
const defaultFormValues = {
|
||||
enabled: false,
|
||||
interval: DAY,
|
||||
customInterval: null,
|
||||
ignored: '',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialValues: FormValues;
|
||||
processing: boolean;
|
||||
processingReset: boolean;
|
||||
t: (...args: unknown[]) => string;
|
||||
interval?: number;
|
||||
customInterval?: number;
|
||||
dispatch: (...args: unknown[]) => unknown;
|
||||
}
|
||||
onSubmit: (values: FormValues) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export const Form = ({ initialValues, processing, processingReset, onSubmit, onReset }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let Form = (props: FormProps) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
processing,
|
||||
submitting,
|
||||
invalid,
|
||||
handleReset,
|
||||
processingReset,
|
||||
t,
|
||||
interval,
|
||||
customInterval,
|
||||
dispatch,
|
||||
} = props;
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
...defaultFormValues,
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
const intervalValue = watch('interval');
|
||||
const customIntervalValue = watch('customInterval');
|
||||
|
||||
useEffect(() => {
|
||||
if (STATS_INTERVALS_DAYS.includes(interval)) {
|
||||
dispatch(change(FORM_NAME.STATS_CONFIG, CUSTOM_INTERVAL, null));
|
||||
if (STATS_INTERVALS_DAYS.includes(intervalValue)) {
|
||||
setValue('customInterval', null);
|
||||
}
|
||||
}, [interval]);
|
||||
}, [intervalValue]);
|
||||
|
||||
const onSubmitForm = (data: FormValues) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
const disableSubmit = isSubmitting || processing || (intervalValue === RETENTION_CUSTOM && !customIntervalValue);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmitForm)}>
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name="enabled"
|
||||
type="checkbox"
|
||||
component={CheckboxField}
|
||||
placeholder={t('statistics_enable')}
|
||||
disabled={processing}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
data-testid="stats_config_enabled"
|
||||
title={t('statistics_enable')}
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="form__label form__label--with-desc">
|
||||
<div className="form__label form__label--with-desc">
|
||||
<Trans>statistics_retention</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>statistics_retention_desc</Trans>
|
||||
|
@ -92,85 +103,105 @@ let Form = (props: FormProps) => {
|
|||
|
||||
<div className="form__group form__group--settings mt-2">
|
||||
<div className="custom-controls-stacked">
|
||||
<Field
|
||||
key={RETENTION_CUSTOM}
|
||||
name="interval"
|
||||
type="radio"
|
||||
component={renderRadioField}
|
||||
value={STATS_INTERVALS_DAYS.includes(interval) ? RETENTION_CUSTOM : interval}
|
||||
placeholder={getIntervalTitle(RETENTION_CUSTOM, t)}
|
||||
normalize={toFloatNumber}
|
||||
disabled={processing}
|
||||
/>
|
||||
{!STATS_INTERVALS_DAYS.includes(interval) && (
|
||||
<div className="form__group--input">
|
||||
<div className="form__desc form__desc--top">{t('custom_retention_input')}</div>
|
||||
<label className="custom-control custom-radio">
|
||||
<input
|
||||
type="radio"
|
||||
data-testid="stats_config_interval"
|
||||
className="custom-control-input"
|
||||
disabled={processing}
|
||||
checked={!STATS_INTERVALS_DAYS.includes(intervalValue)}
|
||||
value={RETENTION_CUSTOM}
|
||||
onChange={(e) => {
|
||||
setValue('interval', parseInt(e.target.value, 10));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field
|
||||
key={RETENTION_CUSTOM_INPUT}
|
||||
name={CUSTOM_INTERVAL}
|
||||
type="number"
|
||||
className="form-control"
|
||||
component={renderInputField}
|
||||
disabled={processing}
|
||||
normalize={toFloatNumber}
|
||||
min={RETENTION_RANGE.MIN}
|
||||
max={RETENTION_RANGE.MAX}
|
||||
<span className="custom-control-label">{getIntervalTitle(RETENTION_CUSTOM)}</span>
|
||||
</label>
|
||||
|
||||
{!STATS_INTERVALS_DAYS.includes(intervalValue) && (
|
||||
<div className="form__group--input">
|
||||
<div className="form__desc form__desc--top">{i18next.t('custom_retention_input')}</div>
|
||||
|
||||
<Controller
|
||||
name="customInterval"
|
||||
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>
|
||||
)}
|
||||
{STATS_INTERVALS_DAYS.map((interval) => (
|
||||
<Field
|
||||
key={interval}
|
||||
name="interval"
|
||||
type="radio"
|
||||
component={renderRadioField}
|
||||
value={interval}
|
||||
placeholder={getIntervalTitle(interval, t)}
|
||||
normalize={toNumber}
|
||||
disabled={processing}
|
||||
/>
|
||||
<label key={interval} className="custom-control custom-radio">
|
||||
<input
|
||||
type="radio"
|
||||
className="custom-control-input"
|
||||
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>
|
||||
|
||||
<label className="form__label form__label--with-desc">
|
||||
<div className="form__label form__label--with-desc">
|
||||
<Trans>ignore_domains_title</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form__desc form__desc--top">
|
||||
<Trans>ignore_domains_desc_stats</Trans>
|
||||
</div>
|
||||
|
||||
<div className="form__group form__group--settings">
|
||||
<Field
|
||||
<Controller
|
||||
name="ignored"
|
||||
type="textarea"
|
||||
className="form-control form-control--textarea font-monospace text-input"
|
||||
component={renderTextareaField}
|
||||
placeholder={t('ignore_domains')}
|
||||
disabled={processing}
|
||||
normalizeOnBlur={trimLinesAndRemoveEmpty}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
data-testid="stats_config_ignored"
|
||||
placeholder={t('ignore_domains')}
|
||||
className="text-input"
|
||||
disabled={processing}
|
||||
error={fieldState.error?.message}
|
||||
trimOnBlur
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="stats_config_save"
|
||||
className="btn btn-success btn-standard btn-large"
|
||||
disabled={
|
||||
submitting ||
|
||||
invalid ||
|
||||
processing ||
|
||||
(!STATS_INTERVALS_DAYS.includes(interval) && !customInterval)
|
||||
}>
|
||||
disabled={disableSubmit}>
|
||||
<Trans>save_btn</Trans>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stats_config_clear"
|
||||
className="btn btn-outline-secondary btn-standard form__button"
|
||||
onClick={() => handleReset()}
|
||||
onClick={onReset}
|
||||
disabled={processingReset}>
|
||||
<Trans>statistics_clear</Trans>
|
||||
</button>
|
||||
|
@ -178,16 +209,3 @@ let Form = (props: FormProps) => {
|
|||
</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);
|
||||
|
|
|
@ -3,7 +3,7 @@ import { withTranslation } from 'react-i18next';
|
|||
|
||||
import Card from '../../ui/Card';
|
||||
|
||||
import Form from './Form';
|
||||
import { Form, FormValues } from './Form';
|
||||
import { HOUR } from '../../../helpers/constants';
|
||||
|
||||
interface StatsConfigProps {
|
||||
|
@ -19,7 +19,7 @@ interface 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 newInterval = customInterval ? customInterval * HOUR : interval;
|
||||
|
||||
|
@ -49,17 +49,11 @@ class StatsConfig extends Component<StatsConfigProps> {
|
|||
render() {
|
||||
const {
|
||||
t,
|
||||
|
||||
interval,
|
||||
|
||||
customInterval,
|
||||
|
||||
processing,
|
||||
|
||||
processingReset,
|
||||
|
||||
ignored,
|
||||
|
||||
enabled,
|
||||
} = this.props;
|
||||
|
||||
|
@ -73,10 +67,10 @@ class StatsConfig extends Component<StatsConfigProps> {
|
|||
enabled,
|
||||
ignored: ignored.join('\n'),
|
||||
}}
|
||||
onSubmit={this.handleFormSubmit}
|
||||
processing={processing}
|
||||
processingReset={processingReset}
|
||||
handleReset={this.handleReset}
|
||||
onSubmit={this.handleFormSubmit}
|
||||
onReset={this.handleReset}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import React, { Component, Fragment } from 'react';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
|
||||
import i18next from 'i18next';
|
||||
import StatsConfig from './StatsConfig';
|
||||
|
||||
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';
|
||||
|
||||
|
@ -24,14 +25,14 @@ const ORDER_KEY = 'order';
|
|||
const SETTINGS = {
|
||||
safebrowsing: {
|
||||
enabled: false,
|
||||
title: 'use_adguard_browsing_sec',
|
||||
subtitle: 'use_adguard_browsing_sec_hint',
|
||||
title: i18next.t('use_adguard_browsing_sec'),
|
||||
subtitle: i18next.t('use_adguard_browsing_sec_hint'),
|
||||
[ORDER_KEY]: 0,
|
||||
},
|
||||
parental: {
|
||||
enabled: false,
|
||||
title: 'use_adguard_parental',
|
||||
subtitle: 'use_adguard_parental_hint',
|
||||
title: i18next.t('use_adguard_parental'),
|
||||
subtitle: i18next.t('use_adguard_parental_hint'),
|
||||
[ORDER_KEY]: 1,
|
||||
},
|
||||
};
|
||||
|
@ -89,9 +90,18 @@ class Settings extends Component<SettingsProps> {
|
|||
renderSettings = (settings: any) =>
|
||||
getObjectKeysSorted(SETTINGS, ORDER_KEY).map((key: any) => {
|
||||
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 = () => {
|
||||
|
@ -106,27 +116,29 @@ class Settings extends Component<SettingsProps> {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
enabled={enabled}
|
||||
title="enforce_safe_search"
|
||||
subtitle="enforce_save_search_hint"
|
||||
handleChange={({ target: { checked: enabled } }) =>
|
||||
this.props.toggleSetting('safesearch', { ...safesearch, enabled })
|
||||
}
|
||||
/>
|
||||
<div className="form__group form__group--checkbox">
|
||||
<Checkbox
|
||||
value={enabled}
|
||||
title={i18next.t('enforce_safe_search')}
|
||||
subtitle={i18next.t('enforce_save_search_hint')}
|
||||
onChange={(checked) =>
|
||||
this.props.toggleSetting('safesearch', { ...safesearch, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form__group--inner">
|
||||
{Object.keys(searches).map((searchKey) => (
|
||||
<Checkbox
|
||||
key={searchKey}
|
||||
enabled={searches[searchKey]}
|
||||
title={captitalizeWords(searchKey)}
|
||||
subtitle=""
|
||||
disabled={!safesearch.enabled}
|
||||
handleChange={({ target: { checked } }: any) =>
|
||||
this.props.toggleSetting('safesearch', { ...safesearch, [searchKey]: checked })
|
||||
}
|
||||
/>
|
||||
<div key={searchKey} className="form__group form__group--checkbox">
|
||||
<Checkbox
|
||||
value={searches[searchKey]}
|
||||
title={captitalizeWords(searchKey)}
|
||||
disabled={!safesearch.enabled}
|
||||
onChange={(checked) =>
|
||||
this.props.toggleSetting('safesearch', { ...safesearch, [searchKey]: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
@ -136,23 +148,14 @@ class Settings extends Component<SettingsProps> {
|
|||
render() {
|
||||
const {
|
||||
settings,
|
||||
|
||||
setStatsConfig,
|
||||
|
||||
resetStats,
|
||||
|
||||
stats,
|
||||
|
||||
queryLogs,
|
||||
|
||||
setLogsConfig,
|
||||
|
||||
clearLogs,
|
||||
|
||||
filtering,
|
||||
|
||||
setFiltersConfig,
|
||||
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
|
@ -163,6 +166,7 @@ class Settings extends Component<SettingsProps> {
|
|||
<PageTitle title={t('general_settings')} />
|
||||
|
||||
{!isDataReady && <Loading />}
|
||||
|
||||
{isDataReady && (
|
||||
<div className="content">
|
||||
<div className="row">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
|
||||
import Guide from '../ui/Guide';
|
||||
import { Guide } from '../ui/Guide';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
|
||||
|
@ -14,10 +14,7 @@ interface SetupGuideProps {
|
|||
t: (id: string) => string;
|
||||
}
|
||||
|
||||
const SetupGuide = ({
|
||||
t,
|
||||
dashboard: { dnsAddresses },
|
||||
}: SetupGuideProps) => (
|
||||
const SetupGuide = ({ t, dashboard: { dnsAddresses } }: SetupGuideProps) => (
|
||||
<div className="guide">
|
||||
<PageTitle title={t('setup_guide')} />
|
||||
|
||||
|
|
|
@ -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);
|
50
client/src/components/ui/Controls/Checkbox/index.tsx
Normal file
50
client/src/components/ui/Controls/Checkbox/index.tsx
Normal 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';
|
45
client/src/components/ui/Controls/Input.tsx
Normal file
45
client/src/components/ui/Controls/Input.tsx
Normal 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';
|
50
client/src/components/ui/Controls/Radio.tsx
Normal file
50
client/src/components/ui/Controls/Radio.tsx
Normal 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';
|
27
client/src/components/ui/Controls/Select.tsx
Normal file
27
client/src/components/ui/Controls/Select.tsx
Normal 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';
|
45
client/src/components/ui/Controls/Textarea.tsx
Normal file
45
client/src/components/ui/Controls/Textarea.tsx
Normal 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';
|
|
@ -7,7 +7,7 @@ import { MOBILE_CONFIG_LINKS } from '../../../helpers/constants';
|
|||
|
||||
import Tabs from '../Tabs';
|
||||
|
||||
import MobileConfigForm from './MobileConfigForm';
|
||||
import { MobileConfigForm } from './MobileConfigForm';
|
||||
import { RootState } from '../../../initialState';
|
||||
|
||||
interface renderLiProps {
|
||||
|
@ -346,7 +346,7 @@ interface GuideProps {
|
|||
dnsAddresses?: unknown[];
|
||||
}
|
||||
|
||||
const Guide = ({ dnsAddresses }: GuideProps) => {
|
||||
export const Guide = ({ dnsAddresses }: GuideProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const serverName = useSelector((state: RootState) => state.encryption?.server_name);
|
||||
|
@ -381,5 +381,3 @@ const Guide = ({ dnsAddresses }: GuideProps) => {
|
|||
Guide.defaultProps = {
|
||||
dnsAddresses: [],
|
||||
};
|
||||
|
||||
export default Guide;
|
||||
|
|
|
@ -1,32 +1,31 @@
|
|||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import i18next from 'i18next';
|
||||
import cn from 'classnames';
|
||||
|
||||
import { getPathWithQueryString } from '../../../helpers/helpers';
|
||||
import { CLIENT_ID_LINK, FORM_NAME, MOBILE_CONFIG_LINKS, STANDARD_HTTPS_PORT } from '../../../helpers/constants';
|
||||
import { renderInputField, renderSelectField, toNumber } from '../../../helpers/form';
|
||||
import { CLIENT_ID_LINK, MOBILE_CONFIG_LINKS, STANDARD_HTTPS_PORT } from '../../../helpers/constants';
|
||||
import { toNumber } from '../../../helpers/form';
|
||||
import {
|
||||
validateConfigClientId,
|
||||
validateServerName,
|
||||
validatePort,
|
||||
validateIsSafePort,
|
||||
} 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) {
|
||||
return (
|
||||
<button type="button" className="btn btn-success btn-standard btn-large disabled">
|
||||
<Trans>download_mobileconfig</Trans>
|
||||
{i18next.t('download_mobileconfig')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const linkParams: { host: string, client_id?: string } = { host };
|
||||
const linkParams: { host: string; client_id?: string } = { host };
|
||||
|
||||
if (clientId) {
|
||||
linkParams.client_id = clientId;
|
||||
|
@ -37,29 +36,48 @@ const getDownloadLink = (host: any, clientId: any, protocol: any, invalid: any)
|
|||
href={getPathWithQueryString(protocol, linkParams)}
|
||||
className={cn('btn btn-success btn-standard btn-large')}
|
||||
download>
|
||||
<Trans>download_mobileconfig</Trans>
|
||||
{i18next.t('download_mobileconfig')}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
interface MobileConfigFormProps {
|
||||
invalid: boolean;
|
||||
}
|
||||
type FormValues = {
|
||||
host: string;
|
||||
clientId: string;
|
||||
protocol: string;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
const MobileConfigForm = ({ invalid }: MobileConfigFormProps) => {
|
||||
const formValues = useSelector((state: RootState) => state.form[FORM_NAME.MOBILE_CONFIG]?.values);
|
||||
type Props = {
|
||||
initialValues?: FormValues;
|
||||
};
|
||||
|
||||
if (!formValues) {
|
||||
return null;
|
||||
}
|
||||
const defaultFormValues = {
|
||||
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 = (
|
||||
<a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer">
|
||||
text
|
||||
</a>
|
||||
);
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
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 = () => {
|
||||
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="row">
|
||||
<div className="col">
|
||||
<label htmlFor="host" className="form__label">
|
||||
{i18next.t('dhcp_table_hostname')}
|
||||
</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="host"
|
||||
type="text"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={i18next.t('form_enter_hostname')}
|
||||
validate={validateServerName}
|
||||
control={control}
|
||||
rules={{ validate: validateServerName }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
data-testid="mobile_config_host"
|
||||
label={t('dhcp_table_hostname')}
|
||||
placeholder={t('form_enter_hostname')}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{protocol === MOBILE_CONFIG_LINKS.DOH && (
|
||||
<div className="col">
|
||||
<label htmlFor="port" className="form__label">
|
||||
{i18next.t('encryption_https')}
|
||||
</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="port"
|
||||
type="number"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={i18next.t('encryption_https')}
|
||||
validate={[validatePort, validateIsSafePort]}
|
||||
normalize={toNumber}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
range: (value) => validatePort(value) || true,
|
||||
safety: (value) => validateIsSafePort(value) || true,
|
||||
},
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
|
@ -110,39 +142,49 @@ const MobileConfigForm = ({ invalid }: MobileConfigFormProps) => {
|
|||
|
||||
<div className="form__group form__group--settings">
|
||||
<label htmlFor="clientId" className="form__label form__label--with-desc">
|
||||
{i18next.t('client_id')}
|
||||
{t('client_id')}
|
||||
</label>
|
||||
|
||||
<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>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="clientId"
|
||||
type="text"
|
||||
component={renderInputField}
|
||||
className="form-control"
|
||||
placeholder={i18next.t('client_id_placeholder')}
|
||||
validate={validateConfigClientId}
|
||||
control={control}
|
||||
rules={{
|
||||
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 className="form__group form__group--settings">
|
||||
<label htmlFor="protocol" className="form__label">
|
||||
{i18next.t('protocol')}
|
||||
</label>
|
||||
|
||||
<Field name="protocol" type="text" component={renderSelectField} className="form-control">
|
||||
<option value={MOBILE_CONFIG_LINKS.DOT}>{i18next.t('dns_over_tls')}</option>
|
||||
|
||||
<option value={MOBILE_CONFIG_LINKS.DOH}>{i18next.t('dns_over_https')}</option>
|
||||
</Field>
|
||||
<Controller
|
||||
name="protocol"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select {...field} label={t('protocol')} data-testid="mobile_config_protocol">
|
||||
<option value={MOBILE_CONFIG_LINKS.DOT}>{t('dns_over_tls')}</option>
|
||||
<option value={MOBILE_CONFIG_LINKS.DOH}>{t('dns_over_https')}</option>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getDownloadLink(getHostName(), clientId, protocol, invalid)}
|
||||
{getDownloadLink(getHostName(), clientId, protocol, !isValid)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default reduxForm({ form: FORM_NAME.MOBILE_CONFIG })(MobileConfigForm);
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { default } from './Guide';
|
||||
export * from './Guide';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { toggleProtection, getClients } from '../actions';
|
||||
import { getStats, getStatsConfig, setStatsConfig } from '../actions/stats';
|
||||
import { getStats, getStatsConfig } from '../actions/stats';
|
||||
import { getAccessList } from '../actions/access';
|
||||
|
||||
import Dashboard from '../components/Dashboard';
|
||||
|
@ -19,7 +19,7 @@ type DispatchProps = {
|
|||
getStats: (...args: unknown[]) => unknown;
|
||||
getStatsConfig: (...args: unknown[]) => unknown;
|
||||
getAccessList: () => (dispatch: any) => void;
|
||||
}
|
||||
};
|
||||
|
||||
const mapDispatchToProps: DispatchProps = {
|
||||
toggleProtection,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { getTlsStatus, setTlsConfig, validateTlsConfig } from '../actions/encryption';
|
||||
|
||||
import Encryption from '../components/Settings/Encryption';
|
||||
import { Encryption } from '../components/Settings/Encryption';
|
||||
|
||||
const mapStateToProps = (state: any) => {
|
||||
const { encryption } = state;
|
||||
|
|
|
@ -91,6 +91,7 @@ export const STANDARD_WEB_PORT = 80;
|
|||
export const STANDARD_HTTPS_PORT = 443;
|
||||
export const DNS_OVER_TLS_PORT = 853;
|
||||
export const DNS_OVER_QUIC_PORT = 853;
|
||||
export const MIN_PORT = 1;
|
||||
export const MAX_PORT = 65535;
|
||||
|
||||
export const EMPTY_DATE = '0001-01-01T00:00:00Z';
|
||||
|
@ -209,7 +210,7 @@ export const WHOIS_ICONS = {
|
|||
|
||||
export const DEFAULT_LOGS_FILTER = {
|
||||
search: '',
|
||||
response_status: '',
|
||||
response_status: 'all',
|
||||
};
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
|
|
@ -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';
|
||||
|
||||
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
|
||||
|
|
|
@ -28,7 +28,7 @@ import {
|
|||
THEMES,
|
||||
} from './constants';
|
||||
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from './localStorageHelper';
|
||||
import { DhcpInterface } from '../initialState';
|
||||
import { DhcpInterface, InstallInterface } from '../initialState';
|
||||
|
||||
/**
|
||||
* @param time {string} The time to format
|
||||
|
@ -217,9 +217,9 @@ export const getInterfaceIp = (option: any) => {
|
|||
return interfaceIP;
|
||||
};
|
||||
|
||||
export const getIpList = (interfaces: DhcpInterface[]) =>
|
||||
export const getIpList = (interfaces: InstallInterface[]) =>
|
||||
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();
|
||||
|
||||
/**
|
||||
|
@ -468,8 +468,6 @@ export const getParamsForClientsSearch = (data: any, param: any, additionalParam
|
|||
* @param {function} [normalizeOnBlur]
|
||||
* @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 checkRewrite = (reason: any) => reason === FILTERED_STATUS.REWRITE;
|
||||
|
|
|
@ -24,7 +24,6 @@ import { ip4ToInt, isValidAbsolutePath } from './form';
|
|||
import { isIpInCidr, parseSubnetMask } from './helpers';
|
||||
|
||||
// Validation functions
|
||||
// https://redux-form.com/8.3.0/examples/fieldlevelvalidation/
|
||||
// If the value is valid, the validation function should return undefined.
|
||||
/**
|
||||
* @param value {string|number}
|
||||
|
@ -35,7 +34,7 @@ export const validateRequiredValue = (value: any) => {
|
|||
if (formattedValue || formattedValue === 0 || (formattedValue && formattedValue.length !== 0)) {
|
||||
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;
|
||||
|
||||
if (ip4ToInt(range_end) <= ip4ToInt(range_start)) {
|
||||
return 'greater_range_start_error';
|
||||
return i18next.t('greater_range_start_error');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
@ -63,7 +62,7 @@ export const validateIpv4RangeEnd = (_: any, allValues: any) => {
|
|||
*/
|
||||
export const validateIpv4 = (value: any) => {
|
||||
if (value && !R_IPV4.test(value)) {
|
||||
return 'form_error_ip4_format';
|
||||
return i18next.t('form_error_ip4_format');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -108,16 +107,16 @@ export const validateNotInRange = (value: any, allValues: any) => {
|
|||
*/
|
||||
export const validateGatewaySubnetMask = (_: any, allValues: any) => {
|
||||
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;
|
||||
|
||||
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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -139,7 +138,7 @@ export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => {
|
|||
const subnetPrefix = parseSubnetMask(subnet_mask);
|
||||
|
||||
if (!isIpInCidr(value, `${gateway_ip}/${subnetPrefix}`)) {
|
||||
return 'subnet_error';
|
||||
return i18next.t('subnet_error');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
@ -149,7 +148,7 @@ export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => {
|
|||
* @param value {string}
|
||||
* @returns {undefined|string}
|
||||
*/
|
||||
export const validateClientId = (value: any) => {
|
||||
export const validateClientId = (value: string) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -165,7 +164,7 @@ export const validateClientId = (value: any) => {
|
|||
R_CLIENT_ID.test(formattedValue)
|
||||
)
|
||||
) {
|
||||
return 'form_error_client_id_format';
|
||||
return i18next.t('form_error_client_id_format');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -180,7 +179,7 @@ export const validateConfigClientId = (value: any) => {
|
|||
}
|
||||
const formattedValue = value.trim();
|
||||
if (formattedValue && !R_CLIENT_ID.test(formattedValue)) {
|
||||
return 'form_error_client_id_format';
|
||||
return i18next.t('form_error_client_id_format');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -195,7 +194,7 @@ export const validateServerName = (value: any) => {
|
|||
}
|
||||
const formattedValue = value ? value.trim() : value;
|
||||
if (formattedValue && !R_DOMAIN.test(formattedValue)) {
|
||||
return 'form_error_server_name';
|
||||
return i18next.t('form_error_server_name');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -206,7 +205,7 @@ export const validateServerName = (value: any) => {
|
|||
*/
|
||||
export const validateIpv6 = (value: any) => {
|
||||
if (value && !R_IPV6.test(value)) {
|
||||
return 'form_error_ip6_format';
|
||||
return i18next.t('form_error_ip6_format');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -217,7 +216,7 @@ export const validateIpv6 = (value: any) => {
|
|||
*/
|
||||
export const validateIp = (value: any) => {
|
||||
if (value && !R_IPV4.test(value) && !R_IPV6.test(value)) {
|
||||
return 'form_error_ip_format';
|
||||
return i18next.t('form_error_ip_format');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -228,7 +227,7 @@ export const validateIp = (value: any) => {
|
|||
*/
|
||||
export const validateMac = (value: any) => {
|
||||
if (value && !R_MAC.test(value)) {
|
||||
return 'form_error_mac_format';
|
||||
return i18next.t('form_error_mac_format');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -239,7 +238,7 @@ export const validateMac = (value: any) => {
|
|||
*/
|
||||
export const validatePort = (value: any) => {
|
||||
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;
|
||||
};
|
||||
|
@ -250,7 +249,7 @@ export const validatePort = (value: any) => {
|
|||
*/
|
||||
export const validateInstallPort = (value: any) => {
|
||||
if (value < 1 || value > MAX_PORT) {
|
||||
return 'form_error_port';
|
||||
return i18next.t('form_error_port');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -264,7 +263,7 @@ export const validatePortTLS = (value: any) => {
|
|||
return undefined;
|
||||
}
|
||||
if (value && (value < STANDARD_WEB_PORT || value > MAX_PORT)) {
|
||||
return 'form_error_port_range';
|
||||
return i18next.t('form_error_port_range');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -281,7 +280,7 @@ export const validatePortQuic = validatePortTLS;
|
|||
*/
|
||||
export const validateIsSafePort = (value: any) => {
|
||||
if (UNSAFE_PORTS.includes(value)) {
|
||||
return 'form_error_port_unsafe';
|
||||
return i18next.t('form_error_port_unsafe');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -292,7 +291,7 @@ export const validateIsSafePort = (value: any) => {
|
|||
*/
|
||||
export const validateDomain = (value: any) => {
|
||||
if (value && !R_HOST.test(value)) {
|
||||
return 'form_error_domain_format';
|
||||
return i18next.t('form_error_domain_format');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
@ -303,7 +302,7 @@ export const validateDomain = (value: any) => {
|
|||
*/
|
||||
export const validateAnswer = (value: any) => {
|
||||
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;
|
||||
};
|
||||
|
@ -314,7 +313,7 @@ export const validateAnswer = (value: any) => {
|
|||
*/
|
||||
export const validatePath = (value: any) => {
|
||||
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;
|
||||
};
|
||||
|
@ -402,7 +401,7 @@ export const validatePlainDns = (value: any, allValues: any) => {
|
|||
const { enabled } = allValues;
|
||||
|
||||
if (!enabled && !value) {
|
||||
return 'encryption_plain_dns_error';
|
||||
return i18next.t('encryption_plain_dns_error');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import {
|
||||
ALL_INTERFACES_IP,
|
||||
BLOCKING_MODES,
|
||||
DAY,
|
||||
DEFAULT_LOGS_FILTER,
|
||||
INSTALL_FIRST_STEP,
|
||||
STANDARD_DNS_PORT,
|
||||
STANDARD_WEB_PORT,
|
||||
TIME_UNITS,
|
||||
|
@ -11,6 +9,14 @@ import {
|
|||
import { DEFAULT_BLOCKING_IPV4, DEFAULT_BLOCKING_IPV6 } from './reducers/dnsConfig';
|
||||
import { Filter } from './helpers/helpers';
|
||||
|
||||
export type InstallInterface = {
|
||||
flags: string;
|
||||
hardware_address: string;
|
||||
ip_addresses: string[];
|
||||
mtu: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type InstallData = {
|
||||
step: number;
|
||||
processingDefault: boolean;
|
||||
|
@ -33,13 +39,7 @@ export type InstallData = {
|
|||
ip: string;
|
||||
error: string;
|
||||
};
|
||||
interfaces: {
|
||||
flags: string;
|
||||
hardware_address: string;
|
||||
ip_addresses: string[];
|
||||
mtu: number;
|
||||
name: string;
|
||||
}[];
|
||||
interfaces: InstallInterface[];
|
||||
dnsVersion: string;
|
||||
};
|
||||
|
||||
|
@ -78,17 +78,17 @@ export type EncryptionData = {
|
|||
};
|
||||
|
||||
export type Client = {
|
||||
blocked_services: string[],
|
||||
blocked_services: string[];
|
||||
blocked_services_schedule: {
|
||||
sun?: { start: number, end: number },
|
||||
mon?: { start: number, end: number },
|
||||
tue?: { start: number, end: number },
|
||||
wed?: { start: number, end: number },
|
||||
thu?: { start: number, end: number },
|
||||
fri?: { start: number, end: number },
|
||||
sat?: { start: number, end: number },
|
||||
sun?: { start: number; end: number };
|
||||
mon?: { start: number; end: number };
|
||||
tue?: { start: number; end: number };
|
||||
wed?: { start: number; end: number };
|
||||
thu?: { start: number; end: number };
|
||||
fri?: { start: number; end: number };
|
||||
sat?: { start: number; end: number };
|
||||
time_zone: string;
|
||||
},
|
||||
};
|
||||
filtering_enabled: boolean;
|
||||
ids: string[];
|
||||
ignore_querylog: boolean;
|
||||
|
@ -104,14 +104,14 @@ export type Client = {
|
|||
upstreams_cache_size: number;
|
||||
use_global_blocked_services: boolean;
|
||||
use_global_settings: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export type AutoClient = {
|
||||
ip: string;
|
||||
name: string;
|
||||
source: string;
|
||||
whois_info: any;
|
||||
}
|
||||
};
|
||||
|
||||
export type DashboardData = {
|
||||
processing: boolean;
|
||||
|
@ -151,13 +151,13 @@ export type SettingsData = {
|
|||
order: number;
|
||||
subtitle: string;
|
||||
title: string;
|
||||
},
|
||||
};
|
||||
safebrowsing: {
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
subtitle: string;
|
||||
title: string;
|
||||
},
|
||||
};
|
||||
safesearch: Record<string, boolean>;
|
||||
};
|
||||
};
|
||||
|
@ -182,7 +182,7 @@ export type RewritesData = {
|
|||
export type NormalizedTopClients = {
|
||||
auto: Record<string, number>;
|
||||
configured: Record<string, number>;
|
||||
}
|
||||
};
|
||||
|
||||
export type StatsData = {
|
||||
processingGetConfig: boolean;
|
||||
|
@ -256,13 +256,13 @@ export type DhcpData = {
|
|||
interface_name: string;
|
||||
check?: {
|
||||
v4?: {
|
||||
other_server?: { found: string; error?: string },
|
||||
static_ip?: {static: string, ip: string},
|
||||
},
|
||||
other_server?: { found: string; error?: string };
|
||||
static_ip?: { static: string; ip: string };
|
||||
};
|
||||
v6?: {
|
||||
other_server?: { found: string; error?: string },
|
||||
static_ip?: {static: string, ip: string},
|
||||
},
|
||||
other_server?: { found: string; error?: string };
|
||||
static_ip?: { static: string; ip: string };
|
||||
};
|
||||
};
|
||||
v4: {
|
||||
gateway_ip: string;
|
||||
|
@ -321,7 +321,7 @@ export type DnsConfigData = {
|
|||
ratelimit_subnet_len_ipv4?: number;
|
||||
ratelimit_subnet_len_ipv6?: number;
|
||||
edns_cs_use_custom?: boolean;
|
||||
edns_cs_custom_ip?: boolean;
|
||||
edns_cs_custom_ip?: string;
|
||||
cache_size?: number;
|
||||
cache_ttl_max?: number;
|
||||
cache_ttl_min?: number;
|
||||
|
@ -391,7 +391,6 @@ export type RootState = {
|
|||
install?: InstallData;
|
||||
toasts: { notices: any[] };
|
||||
loadingBar: any;
|
||||
form: any;
|
||||
};
|
||||
|
||||
export type InstallState = {
|
||||
|
@ -617,5 +616,4 @@ export const initialState: RootState = {
|
|||
},
|
||||
toasts: { notices: [] },
|
||||
loadingBar: {},
|
||||
form: {},
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
import { getIpList, getDnsAddress, getWebAddress } from '../../helpers/helpers';
|
||||
import { ALL_INTERFACES_IP } from '../../helpers/constants';
|
||||
import { DhcpInterface } from '../../initialState';
|
||||
import { InstallInterface } from '../../initialState';
|
||||
|
||||
interface renderItemProps {
|
||||
ip: string;
|
||||
|
@ -28,7 +28,7 @@ const renderItem = ({ ip, port, isDns }: renderItemProps) => {
|
|||
};
|
||||
|
||||
interface AddressListProps {
|
||||
interfaces: DhcpInterface[];
|
||||
interfaces: InstallInterface[];
|
||||
address: string;
|
||||
port: number;
|
||||
isDns?: boolean;
|
||||
|
|
|
@ -1,47 +1,47 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import { withTranslation, Trans } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
|
||||
import i18n from '../../i18n';
|
||||
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import Controls from './Controls';
|
||||
import { validatePasswordLength, validateRequiredValue } from '../../helpers/validators';
|
||||
import { Input } from '../../components/ui/Controls/Input';
|
||||
|
||||
import { renderInputField } from '../../helpers/form';
|
||||
import { FORM_NAME } from '../../helpers/constants';
|
||||
import { validatePasswordLength } from '../../helpers/validators';
|
||||
|
||||
const required = (value: any) => {
|
||||
if (value || value === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return <Trans>form_error_required</Trans>;
|
||||
type AuthFormValues = {
|
||||
username: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
};
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: { confirm_password?: string } = {};
|
||||
|
||||
if (values.confirm_password !== values.password) {
|
||||
errors.confirm_password = i18n.t('form_error_password');
|
||||
}
|
||||
|
||||
return errors;
|
||||
type Props = {
|
||||
onAuthSubmit: (values: AuthFormValues) => void;
|
||||
};
|
||||
|
||||
interface AuthProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
pristine: boolean;
|
||||
invalid: boolean;
|
||||
t: (...args: unknown[]) => string;
|
||||
}
|
||||
export const Auth = ({ onAuthSubmit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm<AuthFormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const Auth = (props: AuthProps) => {
|
||||
const { handleSubmit, pristine, invalid, t } = props;
|
||||
const password = watch('password');
|
||||
|
||||
const validateConfirmPassword = (value: string) => {
|
||||
if (value !== password) {
|
||||
return t('form_error_password');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="setup__step" onSubmit={handleSubmit}>
|
||||
<form className="setup__step" onSubmit={handleSubmit(onAuthSubmit)}>
|
||||
<div className="setup__group">
|
||||
<div className="setup__subtitle">
|
||||
<Trans>install_auth_title</Trans>
|
||||
|
@ -52,65 +52,74 @@ const Auth = (props: AuthProps) => {
|
|||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Trans>install_auth_username</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="username"
|
||||
component={renderInputField}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('install_auth_username_enter')}
|
||||
validate={[required]}
|
||||
autoComplete="username"
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
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 className="form-group">
|
||||
<label>
|
||||
<Trans>install_auth_password</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="password"
|
||||
component={renderInputField}
|
||||
type="password"
|
||||
className="form-control"
|
||||
placeholder={t('install_auth_password_enter')}
|
||||
validate={[required, validatePasswordLength]}
|
||||
autoComplete="new-password"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
required: validateRequiredValue,
|
||||
passwordLength: validatePasswordLength,
|
||||
},
|
||||
}}
|
||||
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 className="form-group">
|
||||
<label>
|
||||
<Trans>install_auth_confirm</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
<Controller
|
||||
name="confirm_password"
|
||||
component={renderInputField}
|
||||
type="password"
|
||||
className="form-control"
|
||||
placeholder={t('install_auth_confirm')}
|
||||
validate={[required]}
|
||||
autoComplete="new-password"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
required: validateRequiredValue,
|
||||
confirmPassword: validateConfirmPassword,
|
||||
},
|
||||
}}
|
||||
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>
|
||||
|
||||
<Controls pristine={pristine} invalid={invalid} />
|
||||
<Controls isDirty={isDirty} isValid={isValid} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default flow([
|
||||
withTranslation(),
|
||||
reduxForm({
|
||||
form: FORM_NAME.INSTALL,
|
||||
destroyOnUnmount: false,
|
||||
forceUnregisterOnUnmount: true,
|
||||
validate,
|
||||
}),
|
||||
])(Auth);
|
||||
|
|
|
@ -32,6 +32,7 @@ class Controls extends Component<ControlsProps> {
|
|||
case 3:
|
||||
return (
|
||||
<button
|
||||
data-testid="install_back"
|
||||
type="button"
|
||||
className="btn btn-secondary btn-lg setup__button"
|
||||
onClick={this.props.prevStep}>
|
||||
|
@ -44,24 +45,16 @@ class Controls extends Component<ControlsProps> {
|
|||
}
|
||||
|
||||
renderNextButton(step: any) {
|
||||
const {
|
||||
nextStep,
|
||||
|
||||
invalid,
|
||||
|
||||
pristine,
|
||||
|
||||
install,
|
||||
|
||||
ip,
|
||||
|
||||
port,
|
||||
} = this.props;
|
||||
const { nextStep, invalid, pristine, install, ip, port } = this.props;
|
||||
|
||||
switch (step) {
|
||||
case 1:
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
|
@ -69,6 +62,7 @@ class Controls extends Component<ControlsProps> {
|
|||
case 3:
|
||||
return (
|
||||
<button
|
||||
data-testid="install_next"
|
||||
type="submit"
|
||||
className="btn btn-success btn-lg setup__button"
|
||||
disabled={invalid || pristine || install.processingSubmit}>
|
||||
|
@ -77,13 +71,18 @@ class Controls extends Component<ControlsProps> {
|
|||
);
|
||||
case 4:
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<button
|
||||
data-testid="install_open_dashboard"
|
||||
type="button"
|
||||
className="btn btn-success btn-lg setup__button"
|
||||
onClick={() => this.props.openDashboard(ip, port)}>
|
||||
|
|
|
@ -1,25 +1,21 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { reduxForm, formValueSelector } from 'redux-form';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import Guide from '../../components/ui/Guide';
|
||||
import { Guide } from '../../components/ui/Guide';
|
||||
|
||||
import Controls from './Controls';
|
||||
|
||||
import AddressList from './AddressList';
|
||||
import { FORM_NAME } from '../../helpers/constants';
|
||||
import { DhcpInterface } from '../../initialState';
|
||||
import { InstallInterface } from '../../initialState';
|
||||
import { DnsConfig } from './Settings';
|
||||
|
||||
interface DevicesProps {
|
||||
interfaces: DhcpInterface[];
|
||||
dnsIp: string;
|
||||
dnsPort: number;
|
||||
}
|
||||
type Props = {
|
||||
interfaces: InstallInterface[];
|
||||
dnsConfig: DnsConfig;
|
||||
};
|
||||
|
||||
let Devices = (props: DevicesProps) => (
|
||||
export const Devices = ({ interfaces, dnsConfig }: Props) => (
|
||||
<div className="setup__step">
|
||||
<div className="setup__group">
|
||||
<div className="setup__subtitle">
|
||||
|
@ -34,7 +30,7 @@ let Devices = (props: DevicesProps) => (
|
|||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
|
@ -44,24 +40,3 @@ let Devices = (props: DevicesProps) => (
|
|||
<Controls />
|
||||
</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);
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
import React from 'react';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
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 = {
|
||||
step: number;
|
||||
};
|
||||
|
||||
const Progress = (props: Props) => (
|
||||
export const Progress = ({ step }: Props) => (
|
||||
<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-inner" style={{ width: `${getProgressPercent(props.step)}%` }} />
|
||||
<div className="setup__progress-inner" style={{ width: `${getProgressPercent(step)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default withTranslation()(Progress);
|
||||
|
|
|
@ -1,32 +1,84 @@
|
|||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Field, reduxForm, formValueSelector } from 'redux-form';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
import i18n, { TFunction } from 'i18next';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import Controls from './Controls';
|
||||
|
||||
import AddressList from './AddressList';
|
||||
|
||||
import { getInterfaceIp } from '../../helpers/helpers';
|
||||
import {
|
||||
ALL_INTERFACES_IP,
|
||||
FORM_NAME,
|
||||
ADDRESS_IN_USE_TEXT,
|
||||
PORT_53_FAQ_LINK,
|
||||
STATUS_RESPONSE,
|
||||
STANDARD_DNS_PORT,
|
||||
STANDARD_WEB_PORT,
|
||||
MAX_PORT,
|
||||
MIN_PORT,
|
||||
} from '../../helpers/constants';
|
||||
|
||||
import { renderInputField, toNumber } from '../../helpers/form';
|
||||
import { validateRequiredValue, validateInstallPort } from '../../helpers/validators';
|
||||
import { DhcpInterface } from '../../initialState';
|
||||
import { validateRequiredValue } from '../../helpers/validators';
|
||||
import { InstallInterface } 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[]) =>
|
||||
Object.values(interfaces).map((option: DhcpInterface) => {
|
||||
const validateInstallPort = (value: number) => {
|
||||
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;
|
||||
|
||||
if (option && ip_addresses?.length > 0) {
|
||||
|
@ -43,113 +95,70 @@ const renderInterfaces = (interfaces: DhcpInterface[]) =>
|
|||
return null;
|
||||
});
|
||||
|
||||
type Props = {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
handleChange?: (...args: unknown[]) => unknown;
|
||||
handleFix: (...args: unknown[]) => unknown;
|
||||
validateForm?: (...args: unknown[]) => unknown;
|
||||
webIp: string;
|
||||
dnsIp: string;
|
||||
config: {
|
||||
export const Settings = ({ handleSubmit, handleFix, validateForm, config, interfaces }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const defaultValues = {
|
||||
web: {
|
||||
status: string;
|
||||
can_autofix: boolean;
|
||||
};
|
||||
ip: config.web.ip || ALL_INTERFACES_IP,
|
||||
port: config.web.port || STANDARD_WEB_PORT,
|
||||
},
|
||||
dns: {
|
||||
status: string;
|
||||
can_autofix: boolean;
|
||||
};
|
||||
staticIp: {
|
||||
ip: string;
|
||||
static: string;
|
||||
};
|
||||
ip: config.dns.ip || ALL_INTERFACES_IP,
|
||||
port: config.dns.port || STANDARD_DNS_PORT,
|
||||
},
|
||||
};
|
||||
webPort?: number;
|
||||
dnsPort?: number;
|
||||
interfaces: DhcpInterface[];
|
||||
invalid: boolean;
|
||||
initialValues?: object;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
class Settings extends Component<Props> {
|
||||
componentDidMount() {
|
||||
const { webIp, webPort, dnsIp, dnsPort } = this.props;
|
||||
const {
|
||||
control,
|
||||
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: {
|
||||
ip: webIp,
|
||||
port: webPort,
|
||||
ip: webIpVal,
|
||||
port: webPortVal,
|
||||
},
|
||||
dns: {
|
||||
ip: dnsIp,
|
||||
port: dnsPort,
|
||||
ip: dnsIpVal,
|
||||
port: 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;
|
||||
}, [webIpVal, webPortVal, dnsIpVal, dnsPortVal]);
|
||||
|
||||
const handleAutofix = (type: string) => {
|
||||
const web = {
|
||||
ip: webIp,
|
||||
port: webPort,
|
||||
ip: watchFields.web?.ip,
|
||||
port: watchFields.web?.port,
|
||||
autofix: false,
|
||||
};
|
||||
const dns = {
|
||||
ip: dnsIp,
|
||||
port: dnsPort,
|
||||
ip: watchFields.dns?.ip,
|
||||
port: watchFields.dns?.port,
|
||||
autofix: false,
|
||||
};
|
||||
const set_static_ip = false;
|
||||
|
@ -163,276 +172,292 @@ class Settings extends Component<Props> {
|
|||
handleFix(web, dns, set_static_ip);
|
||||
};
|
||||
|
||||
handleStaticIp = (ip: any) => {
|
||||
const {
|
||||
webIp,
|
||||
|
||||
webPort,
|
||||
|
||||
dnsIp,
|
||||
|
||||
dnsPort,
|
||||
|
||||
handleFix,
|
||||
} = this.props;
|
||||
|
||||
const handleStaticIp = (ip: string) => {
|
||||
const web = {
|
||||
ip: webIp,
|
||||
port: webPort,
|
||||
ip: watchFields.web?.ip,
|
||||
port: watchFields.web?.port,
|
||||
autofix: false,
|
||||
};
|
||||
const dns = {
|
||||
ip: dnsIp,
|
||||
port: dnsPort,
|
||||
ip: watchFields.dns?.ip,
|
||||
port: watchFields.dns?.port,
|
||||
autofix: false,
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
handleSubmit,
|
||||
const getStaticIpMessage = useCallback(
|
||||
(staticIp: StaticIpType) => {
|
||||
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,
|
||||
|
||||
interfaces,
|
||||
|
||||
invalid,
|
||||
|
||||
config,
|
||||
|
||||
t,
|
||||
} = this.props;
|
||||
const { status: webStatus, can_autofix: isWebFixAvailable } = config.web;
|
||||
const { status: dnsStatus, can_autofix: isDnsFixAvailable } = config.dns;
|
||||
const { staticIp } = config;
|
||||
|
||||
return (
|
||||
<form className="setup__step" onSubmit={handleSubmit}>
|
||||
<div className="setup__group">
|
||||
<div className="setup__subtitle">
|
||||
<Trans>install_settings_title</Trans>
|
||||
<div className="row">
|
||||
<div className="col-8">
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Trans>install_settings_listen</Trans>
|
||||
</label>
|
||||
<Controller
|
||||
name="web.ip"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select {...field} data-testid="install_web_ip">
|
||||
<option value={ALL_INTERFACES_IP}>
|
||||
{t('install_settings_all_interfaces')}
|
||||
</option>
|
||||
{renderInterfaces(interfaces)}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-8">
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Trans>install_settings_listen</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
name="web.ip"
|
||||
component="select"
|
||||
className="form-control custom-select"
|
||||
onChange={handleChange}>
|
||||
<option value={ALL_INTERFACES_IP}>
|
||||
{this.props.t('install_settings_all_interfaces')}
|
||||
</option>
|
||||
{renderInterfaces(interfaces)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Trans>install_settings_port</Trans>
|
||||
</label>
|
||||
<Controller
|
||||
name="web.port"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: {
|
||||
required: validateRequiredValue,
|
||||
installPort: validateInstallPort,
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
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 className="col-4">
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Trans>install_settings_port</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
name="web.port"
|
||||
component={renderInputField}
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder={STANDARD_WEB_PORT.toString()}
|
||||
validate={[validateInstallPort, validateRequiredValue]}
|
||||
normalize={toNumber}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="col-12">
|
||||
{webStatus && (
|
||||
<div className="setup__error text-danger">
|
||||
{webStatus}
|
||||
{isWebFixAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="install_web_fix"
|
||||
className="btn btn-secondary btn-sm ml-2"
|
||||
onClick={() => handleAutofix('web')}>
|
||||
<Trans>fix</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="col-12">
|
||||
{webStatus && (
|
||||
<hr className="divider--small" />
|
||||
</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">
|
||||
{webStatus}
|
||||
{isWebFixAvailable && (
|
||||
{dnsStatus}
|
||||
{isDnsFixAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="install_dns_fix"
|
||||
className="btn btn-secondary btn-sm ml-2"
|
||||
onClick={() => this.handleAutofix('web')}>
|
||||
onClick={() => handleAutofix('dns')}>
|
||||
<Trans>fix</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="divider--small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setup__desc">
|
||||
<Trans>install_settings_interface_link</Trans>
|
||||
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
{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" />
|
||||
</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>
|
||||
<hr className="divider--small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setup__group">
|
||||
<div className="setup__subtitle">
|
||||
<Trans>static_ip</Trans>
|
||||
</div>
|
||||
<div className="setup__desc">
|
||||
<Trans>install_settings_dns_desc</Trans>
|
||||
|
||||
<div className="mb-2">
|
||||
<Trans>static_ip_desc</Trans>
|
||||
<div className="mt-1">
|
||||
<AddressList
|
||||
interfaces={interfaces}
|
||||
address={watchFields.dns?.ip}
|
||||
port={watchFields.dns?.port}
|
||||
isDns={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.getStaticIpMessage(staticIp)}
|
||||
<div className="setup__group">
|
||||
<div className="setup__subtitle">
|
||||
<Trans>static_ip</Trans>
|
||||
</div>
|
||||
|
||||
<Controls invalid={invalid} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
<div className="mb-2">
|
||||
<Trans>static_ip_desc</Trans>
|
||||
</div>
|
||||
|
||||
const selector = formValueSelector(FORM_NAME.INSTALL);
|
||||
{getStaticIpMessage(staticIp)}
|
||||
</div>
|
||||
|
||||
const SettingsForm = connect((state) => {
|
||||
const webIp = selector(state, 'web.ip');
|
||||
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);
|
||||
<Controls invalid={!isValid} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { reduxForm, formValueSelector } from 'redux-form';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import Controls from './Controls';
|
||||
import { FORM_NAME } from '../../helpers/constants';
|
||||
import { WebConfig } from './Settings';
|
||||
|
||||
interface SubmitProps {
|
||||
webIp: string;
|
||||
webPort: number;
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
pristine: boolean;
|
||||
submitting: boolean;
|
||||
openDashboard: (...args: unknown[]) => unknown;
|
||||
}
|
||||
type Props = {
|
||||
webConfig: WebConfig;
|
||||
openDashboard: (ip: string, port: number) => void;
|
||||
};
|
||||
|
||||
let Submit = (props: SubmitProps) => (
|
||||
export const Submit = ({ openDashboard, webConfig }: Props) => (
|
||||
<div className="setup__step">
|
||||
<div className="setup__group">
|
||||
<h1 className="setup__title">
|
||||
|
@ -29,27 +22,6 @@ let Submit = (props: SubmitProps) => (
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Controls openDashboard={props.openDashboard} ip={props.webIp} port={props.webPort} />
|
||||
<Controls openDashboard={openDashboard} ip={webConfig.ip} port={webConfig.port} />
|
||||
</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);
|
||||
|
|
|
@ -1,101 +1,80 @@
|
|||
import React, { Component, Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import React, { useEffect, Fragment } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import * as actionCreators from '../../actions/install';
|
||||
|
||||
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 Greeting from './Greeting';
|
||||
|
||||
import Settings from './Settings';
|
||||
|
||||
import Auth from './Auth';
|
||||
|
||||
import Devices from './Devices';
|
||||
|
||||
import Submit from './Submit';
|
||||
|
||||
import Progress from './Progress';
|
||||
|
||||
import { ConfigType, DnsConfig, Settings, WebConfig } from './Settings';
|
||||
import { Devices } from './Devices';
|
||||
import { Submit } from './Submit';
|
||||
import { Progress } from './Progress';
|
||||
import { Auth } from './Auth';
|
||||
import Toasts from '../../components/Toasts';
|
||||
|
||||
import Footer from '../../components/ui/Footer';
|
||||
|
||||
import Icons from '../../components/ui/Icons';
|
||||
|
||||
import { Logo } from '../../components/ui/svg/logo';
|
||||
|
||||
import './Setup.css';
|
||||
import '../../components/ui/Tabler.css';
|
||||
import { InstallInterface, InstallState } from '../../initialState';
|
||||
|
||||
interface SetupProps {
|
||||
getDefaultAddresses: (...args: unknown[]) => unknown;
|
||||
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;
|
||||
}
|
||||
export const Setup = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
class Setup extends Component<SetupProps> {
|
||||
componentDidMount() {
|
||||
this.props.getDefaultAddresses();
|
||||
}
|
||||
const install = useSelector((state: InstallState) => state.install);
|
||||
const { processingDefault, step, web, dns, staticIp, interfaces } = install;
|
||||
|
||||
handleFormSubmit = (values: any) => {
|
||||
const { staticIp, ...config } = values;
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
handleFix = (web: any, dns: any, set_static_ip: any) => {
|
||||
this.props.checkConfig({ web, dns, set_static_ip });
|
||||
const handleFix = (web: WebConfig, dns: DnsConfig, set_static_ip: boolean) => {
|
||||
dispatch(actionCreators.checkConfig({ web, dns, set_static_ip }));
|
||||
};
|
||||
|
||||
openDashboard = (ip: any, port: any) => {
|
||||
const openDashboard = (ip: string, port: number) => {
|
||||
let address = getWebAddress(ip, port);
|
||||
|
||||
if (ip === ALL_INTERFACES_IP) {
|
||||
address = getWebAddress(window.location.hostname, port);
|
||||
}
|
||||
|
||||
window.location.replace(address);
|
||||
};
|
||||
|
||||
nextStep = () => {
|
||||
if (this.props.install.step < INSTALL_TOTAL_STEPS) {
|
||||
this.props.nextStep();
|
||||
const handleNextStep = () => {
|
||||
if (step < INSTALL_TOTAL_STEPS) {
|
||||
dispatch(actionCreators.nextStep());
|
||||
}
|
||||
};
|
||||
|
||||
prevStep = () => {
|
||||
if (this.props.install.step > INSTALL_FIRST_STEP) {
|
||||
this.props.prevStep();
|
||||
}
|
||||
};
|
||||
|
||||
renderPage(step: any, config: any, interfaces: any) {
|
||||
const renderPage = (step: number, config: ConfigType, interfaces: InstallInterface[]) => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return <Greeting />;
|
||||
|
@ -105,55 +84,41 @@ class Setup extends Component<SetupProps> {
|
|||
config={config}
|
||||
initialValues={config}
|
||||
interfaces={interfaces}
|
||||
onSubmit={this.nextStep}
|
||||
onChange={this.handleFormChange}
|
||||
validateForm={this.handleFormChange}
|
||||
handleFix={this.handleFix}
|
||||
handleSubmit={handleNextStep}
|
||||
validateForm={checkConfig}
|
||||
handleFix={handleFix}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return <Auth onSubmit={this.handleFormSubmit} />;
|
||||
return <Auth onAuthSubmit={handleFormSubmit} />;
|
||||
case 4:
|
||||
return <Devices interfaces={interfaces} />;
|
||||
return <Devices interfaces={interfaces} dnsConfig={dns} />;
|
||||
case 5:
|
||||
return <Submit openDashboard={this.openDashboard} />;
|
||||
return <Submit openDashboard={openDashboard} webConfig={web} />;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (processingDefault) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { processingDefault, step, web, dns, staticIp, interfaces } = this.props.install;
|
||||
return (
|
||||
<>
|
||||
<div className="setup">
|
||||
<div className="setup__container">
|
||||
<Logo className="setup__logo" />
|
||||
{renderPage(step, { web, dns, staticIp }, interfaces)}
|
||||
<Progress step={step} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<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 />
|
||||
|
||||
<Footer />
|
||||
<Toasts />
|
||||
|
||||
<Toasts />
|
||||
|
||||
<Icons />
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: any) => {
|
||||
const { install, toasts } = state;
|
||||
const props = { install, toasts };
|
||||
return props;
|
||||
<Icons />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, actionCreators)(Setup);
|
||||
|
|
|
@ -8,7 +8,7 @@ import configureStore from '../configureStore';
|
|||
import reducers from '../reducers/install';
|
||||
import '../i18n';
|
||||
|
||||
import Setup from './Setup';
|
||||
import { Setup } from './Setup';
|
||||
import { InstallState } from '../initialState';
|
||||
|
||||
const store = configureStore<InstallState>(reducers, {});
|
||||
|
|
|
@ -1,67 +1,84 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, reduxForm } from 'redux-form';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import flow from 'lodash/flow';
|
||||
|
||||
import { renderInputField } from '../../helpers/form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '../../components/ui/Controls/Input';
|
||||
import { validateRequiredValue } from '../../helpers/validators';
|
||||
import { FORM_NAME } from '../../helpers/constants';
|
||||
|
||||
interface LoginFormProps {
|
||||
handleSubmit: (...args: unknown[]) => string;
|
||||
submitting: boolean;
|
||||
invalid: boolean;
|
||||
export type LoginFormValues = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type LoginFormProps = {
|
||||
onSubmit: (data: LoginFormValues) => void;
|
||||
processing: boolean;
|
||||
t: (...args: unknown[]) => string;
|
||||
}
|
||||
};
|
||||
|
||||
const Form = (props: LoginFormProps) => {
|
||||
const { handleSubmit, processing, invalid, t } = props;
|
||||
const Form = ({ onSubmit, processing }: LoginFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isValid },
|
||||
} = useForm<LoginFormValues>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="card">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card">
|
||||
<div className="card-body p-6">
|
||||
<div className="form__group form__group--settings">
|
||||
<label className="form__label" htmlFor="username">
|
||||
<Trans>username_label</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
id="username1"
|
||||
<Controller
|
||||
name="username"
|
||||
type="text"
|
||||
className="form-control"
|
||||
component={renderInputField}
|
||||
placeholder={t('username_placeholder')}
|
||||
autoComplete="username"
|
||||
autocapitalize="none"
|
||||
disabled={processing}
|
||||
validate={[validateRequiredValue]}
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
data-testid="username"
|
||||
type="text"
|
||||
label={t('username_label')}
|
||||
placeholder={t('username_placeholder')}
|
||||
error={fieldState.error?.message}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form__group form__group--settings">
|
||||
<label className="form__label" htmlFor="password">
|
||||
<Trans>password_label</Trans>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
id="password"
|
||||
<Controller
|
||||
name="password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
component={renderInputField}
|
||||
placeholder={t('password_placeholder')}
|
||||
autoComplete="current-password"
|
||||
disabled={processing}
|
||||
validate={[validateRequiredValue]}
|
||||
control={control}
|
||||
rules={{ validate: validateRequiredValue }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Input
|
||||
{...field}
|
||||
data-testid="password"
|
||||
type="password"
|
||||
label={t('username_label')}
|
||||
placeholder={t('password_placeholder')}
|
||||
error={fieldState.error?.message}
|
||||
autoComplete="current-password"
|
||||
disabled={processing}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-footer">
|
||||
<button type="submit" className="btn btn-success btn-block" disabled={processing || invalid}>
|
||||
<Trans>sign_in</Trans>
|
||||
<button
|
||||
data-testid="sign_in"
|
||||
type="submit"
|
||||
className="btn btn-success btn-block"
|
||||
disabled={processing || !isValid}>
|
||||
{t('sign_in')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -69,4 +86,4 @@ const Form = (props: LoginFormProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default flow([withTranslation(), reduxForm({ form: FORM_NAME.LOGIN })])(Form);
|
||||
export default Form;
|
||||
|
|
|
@ -1,95 +1,68 @@
|
|||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import flow from 'lodash/flow';
|
||||
import { withTranslation, Trans } from 'react-i18next';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import * as actionCreators from '../../actions/login';
|
||||
|
||||
import { Logo } from '../../components/ui/svg/logo';
|
||||
|
||||
import Toasts from '../../components/Toasts';
|
||||
|
||||
import Footer from '../../components/ui/Footer';
|
||||
|
||||
import Icons from '../../components/ui/Icons';
|
||||
|
||||
import Form from './Form';
|
||||
import Form, { LoginFormValues } from './Form';
|
||||
|
||||
import './Login.css';
|
||||
import '../../components/ui/Tabler.css';
|
||||
import { LoginState } from '../../initialState';
|
||||
|
||||
type LoginProps = {
|
||||
login: {
|
||||
processingLogin: boolean;
|
||||
};
|
||||
processLogin: (args: { name: string; password: string }) => unknown;
|
||||
};
|
||||
export const Login = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { processingLogin } = useSelector((state: LoginState) => state.login);
|
||||
const [isForgotPasswordVisible, setIsForgotPasswordVisible] = useState(false);
|
||||
|
||||
type LoginState = {
|
||||
isForgotPasswordVisible: boolean;
|
||||
};
|
||||
|
||||
class Login extends Component<LoginProps, LoginState> {
|
||||
state = {
|
||||
isForgotPasswordVisible: false,
|
||||
const handleSubmit = ({ username: name, password }: LoginFormValues) => {
|
||||
dispatch(actionCreators.processLogin({ name, password }));
|
||||
};
|
||||
|
||||
handleSubmit = ({ username: name, password }: { username: string; password: string }) => {
|
||||
this.props.processLogin({ name, password });
|
||||
const toggleText = () => {
|
||||
setIsForgotPasswordVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
toggleText = () => {
|
||||
this.setState((prevState) => ({
|
||||
isForgotPasswordVisible: !prevState.isForgotPasswordVisible,
|
||||
}));
|
||||
};
|
||||
|
||||
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>
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="login__form">
|
||||
<div className="text-center mb-6">
|
||||
<Logo className="h-6 login__logo" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ login, toasts }: any) => ({ login, toasts });
|
||||
|
||||
export default flow([withTranslation(), connect(mapStateToProps, actionCreators)])(Login);
|
||||
<Footer />
|
||||
<Toasts />
|
||||
<Icons />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,7 +8,7 @@ import configureStore from '../configureStore';
|
|||
import reducers from '../reducers/login';
|
||||
import '../i18n';
|
||||
|
||||
import Login from './Login';
|
||||
import { Login } from './Login';
|
||||
import { LoginState } from '../initialState';
|
||||
|
||||
const store = configureStore<LoginState>(reducers, {});
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { combineReducers } from 'redux';
|
||||
import { loadingBarReducer } from 'react-redux-loading-bar';
|
||||
|
||||
import { reducer as formReducer } from 'redux-form';
|
||||
import toasts from './toasts';
|
||||
import encryption from './encryption';
|
||||
import clients from './clients';
|
||||
|
@ -31,5 +30,4 @@ export default combineReducers({
|
|||
stats,
|
||||
dnsConfig,
|
||||
loadingBar: loadingBarReducer,
|
||||
form: formReducer,
|
||||
});
|
||||
|
|
|
@ -2,23 +2,21 @@ import { combineReducers } from 'redux';
|
|||
|
||||
import { handleActions } from 'redux-actions';
|
||||
|
||||
import { reducer as formReducer } from 'redux-form';
|
||||
|
||||
import * as actions from '../actions/install';
|
||||
import toasts from './toasts';
|
||||
import { ALL_INTERFACES_IP, INSTALL_FIRST_STEP, STANDARD_DNS_PORT, STANDARD_WEB_PORT } from '../helpers/constants';
|
||||
|
||||
const install = handleActions(
|
||||
{
|
||||
[actions.getDefaultAddressesRequest.toString().toString()]: (state: any) => ({
|
||||
[actions.getDefaultAddressesRequest.toString()]: (state: any) => ({
|
||||
...state,
|
||||
processingDefault: true,
|
||||
}),
|
||||
[actions.getDefaultAddressesFailure.toString().toString()]: (state: any) => ({
|
||||
[actions.getDefaultAddressesFailure.toString()]: (state: any) => ({
|
||||
...state,
|
||||
processingDefault: false,
|
||||
}),
|
||||
[actions.getDefaultAddressesSuccess.toString().toString()]: (state: any, { payload }: any) => {
|
||||
[actions.getDefaultAddressesSuccess.toString()]: (state: any, { payload }: any) => {
|
||||
const { interfaces, version } = payload;
|
||||
const web = { ...state.web, port: payload.web_port };
|
||||
const dns = { ...state.dns, port: payload.dns_port };
|
||||
|
@ -35,37 +33,37 @@ const install = handleActions(
|
|||
return newState;
|
||||
},
|
||||
|
||||
[actions.nextStep.toString().toString()]: (state: any) => ({
|
||||
[actions.nextStep.toString()]: (state: any) => ({
|
||||
...state,
|
||||
step: state.step + 1,
|
||||
}),
|
||||
[actions.prevStep.toString().toString()]: (state: any) => ({
|
||||
[actions.prevStep.toString()]: (state: any) => ({
|
||||
...state,
|
||||
step: state.step - 1,
|
||||
}),
|
||||
|
||||
[actions.setAllSettingsRequest.toString().toString()]: (state: any) => ({
|
||||
[actions.setAllSettingsRequest.toString()]: (state: any) => ({
|
||||
...state,
|
||||
processingSubmit: true,
|
||||
}),
|
||||
[actions.setAllSettingsFailure.toString().toString()]: (state: any) => ({
|
||||
[actions.setAllSettingsFailure.toString()]: (state: any) => ({
|
||||
...state,
|
||||
processingSubmit: false,
|
||||
}),
|
||||
[actions.setAllSettingsSuccess.toString().toString()]: (state: any) => ({
|
||||
[actions.setAllSettingsSuccess.toString()]: (state: any) => ({
|
||||
...state,
|
||||
processingSubmit: false,
|
||||
}),
|
||||
|
||||
[actions.checkConfigRequest.toString().toString()]: (state: any) => ({
|
||||
[actions.checkConfigRequest.toString()]: (state: any) => ({
|
||||
...state,
|
||||
processingCheck: true,
|
||||
}),
|
||||
[actions.checkConfigFailure.toString().toString()]: (state: any) => ({
|
||||
[actions.checkConfigFailure.toString()]: (state: any) => ({
|
||||
...state,
|
||||
processingCheck: false,
|
||||
}),
|
||||
[actions.checkConfigSuccess.toString().toString()]: (state: any, { payload }: any) => {
|
||||
[actions.checkConfigSuccess.toString()]: (state: any, { payload }: any) => {
|
||||
const web = { ...state.web, ...payload.web };
|
||||
const dns = { ...state.dns, ...payload.dns };
|
||||
const staticIp = { ...state.staticIp, ...payload.static_ip };
|
||||
|
@ -110,5 +108,4 @@ const install = handleActions(
|
|||
export default combineReducers({
|
||||
install,
|
||||
toasts,
|
||||
form: formReducer,
|
||||
});
|
||||
|
|
|
@ -2,8 +2,6 @@ import { combineReducers } from 'redux';
|
|||
|
||||
import { handleActions } from 'redux-actions';
|
||||
|
||||
import { reducer as formReducer } from 'redux-form';
|
||||
|
||||
import * as actions from '../actions/login';
|
||||
import toasts from './toasts';
|
||||
|
||||
|
@ -33,5 +31,4 @@ const login = handleActions(
|
|||
export default combineReducers({
|
||||
login,
|
||||
toasts,
|
||||
form: formReducer,
|
||||
});
|
||||
|
|
4
client/tests/constants.ts
Normal file
4
client/tests/constants.ts
Normal 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';
|
64
client/tests/e2e/dhcp.spec.ts
Normal file
64
client/tests/e2e/dhcp.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
31
client/tests/e2e/globalSetup.ts
Normal file
31
client/tests/e2e/globalSetup.ts
Normal 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;
|
11
client/tests/e2e/globalTeardown.ts
Normal file
11
client/tests/e2e/globalTeardown.ts
Normal 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;
|
16
client/tests/e2e/login.spec.ts
Normal file
16
client/tests/e2e/login.spec.ts
Normal 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'));
|
||||
});
|
||||
});
|
65
client/tests/helpers/network.ts
Normal file
65
client/tests/helpers/network.ts
Normal 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
8
client/vitest.config.ts
vendored
Normal 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
Loading…
Add table
Add a link
Reference in a new issue