Pull request 2322: ADG-9415

Merge in DNS/adguard-home from ADG-9415 to master

Squashed commit of the following:

commit 76bf99499a
Merge: 29529970a 0389515ee
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Feb 26 18:31:41 2025 +0300

    Merge branch 'master' into ADG-9415

commit 29529970a3
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

commit b49790daf8
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Feb 24 15:30:18 2025 +0300

    fix default lease duration value

commit cb307472ec
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Feb 24 10:35:26 2025 +0300

    fix default response status

commit 115e743e1a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Feb 24 10:32:46 2025 +0300

    fix upstream description

commit 26b0eddaca
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Feb 18 17:40:41 2025 +0300

    use const for test config file

commit 58faa7c537
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Feb 18 17:31:04 2025 +0300

    fix install config

commit 0a3346d911
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Feb 17 15:25:23 2025 +0300

    fix install check config

commit 17c4c26ea8
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Feb 14 17:18:20 2025 +0300

    fix query log

commit 14a2685ae3
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Feb 14 15:52:36 2025 +0300

    fix dhcp initial values

commit e7a8db7afd
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Feb 14 14:37:24 2025 +0300

    fix encryption form values

commit 1c8917f7ac
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Feb 14 14:07:29 2025 +0300

    fix blocked services submit

commit 4dfa536cea
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Feb 14 13:50:47 2025 +0300

    dns config ip validation

commit 4fee83fe13
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Feb 12 17:49:54 2025 +0300

    add playwright warning

commit 8c2f36e7a6
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Feb 11 18:36:18 2025 +0300

    fix config file name

commit 83db5f33dc
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Feb 11 16:16:43 2025 +0300

    temp config file

commit 9080c1620f
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Feb 11 15:01:46 2025 +0300

    update readme

commit ee1520307f
Merge: fd12e33c0 2fe2d254b
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Feb 11 14:44:06 2025 +0300

    Merge branch 'master' into ADG-9415

commit fd12e33c06
Author: Igor Lobanov <bniwredyc@gmail.com>
Date:   Mon Feb 10 10:29:43 2025 +0100

    added typecheck on build, fixed eslint

commit b3849eebc4
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:
Ildar Kamalov 2025-02-26 19:37:52 +03:00
parent 0389515ee3
commit 8b2ab8ea87
102 changed files with 7075 additions and 10256 deletions

View file

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

4
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -290,6 +290,22 @@ When you need to debug the frontend without recompiling the production version e
[targ-docker]: https://github.com/AdguardTeam/AdGuardHome/tree/master/scripts#build-dockersh-build-a-multi-architecture-docker-image
[targ-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.

View file

@ -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'

View file

@ -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
View file

@ -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": [

View file

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

8728
client/package-lock.json generated vendored

File diff suppressed because it is too large Load diff

23
client/package.json vendored
View file

@ -7,11 +7,14 @@
"build-prod": "cross-env BUILD_ENV=prod webpack --config webpack.prod.js",
"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
View file

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

View file

@ -1,3 +1,5 @@
import { describe, expect, test, afterEach, vi, beforeEach, it } from 'vitest';
import { sortIp, countClientsStatistics, findAddressType, subnetMaskToBitMask } from '../helpers/helpers';
import { 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);
});
});

View file

@ -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: {} };

View file

@ -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());

View file

@ -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(

View file

@ -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;

View file

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

View file

@ -1,208 +1,152 @@
import React from 'react';
import { 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);

View file

@ -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;

View file

@ -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> &nbsp;
<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;

View file

@ -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>

View file

@ -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);

View file

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

View file

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import 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>
</>

View file

@ -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);

View file

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

View file

@ -2,17 +2,16 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { 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>
);
};

View file

@ -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));
}
}, []);

View file

@ -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

View file

@ -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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,8 +4,15 @@ import { Trans, withTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { 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}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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';

View file

@ -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">

View file

@ -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 && <>&nbsp;({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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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>
);
};

View file

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

View file

@ -1,39 +1,115 @@
import React from 'react';
import { 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>
</>
);
};

View file

@ -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);

View file

@ -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>

View file

@ -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;
}

View file

@ -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);

View file

@ -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>

View file

@ -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">

View file

@ -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')} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import { MOBILE_CONFIG_LINKS } from '../../../helpers/constants';
import Tabs from '../Tabs';
import 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;

View file

@ -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);

View file

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

View file

@ -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,

View file

@ -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;

View file

@ -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';

View file

@ -1,304 +1,5 @@
import React, { Fragment } from 'react';
import { Trans } from 'react-i18next';
import cn from 'classnames';
import { createOnBlurHandler } from './helpers';
import { R_MAC_WITHOUT_COLON, R_UNIX_ABSOLUTE_PATH, R_WIN_ABSOLUTE_PATH } from './constants';
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

View file

@ -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;

View file

@ -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;

View file

@ -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: {},
};

View file

@ -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;

View file

@ -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);

View file

@ -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)}>

View file

@ -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);

View file

@ -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);

View file

@ -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>
);
};

View file

@ -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);

View file

@ -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);

View file

@ -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, {});

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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, {});

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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,
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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