Compare commits

...

37 Commits

Author SHA1 Message Date
Ildar Kamalov
fe5ed799da changelog 2025-03-11 10:07:01 +03:00
Ildar Kamalov
5a33876ac4 fix 2025-03-11 09:58:13 +03:00
Ildar Kamalov
bcffb41864 ADG-9783 add client id and dns record type to check form 2025-03-10 20:00:45 +03:00
Eugene Burkov
ac8a0ec570 Pull request 2360: Update all
Merge in DNS/adguard-home from upd-all to master

Squashed commit of the following:

commit a9ec07f3810d94f1119d727a317db05a5f0d2d81
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Mar 10 18:26:30 2025 +0300

    client: upd data

commit f1cba36a0048e47f28a9ed481d51529e82e24283
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Mar 10 18:18:46 2025 +0300

    all: upd proxy
2025-03-10 18:38:11 +03:00
Stanislav Chzhen
3255efcaf3 Pull request 2354: AGDNS-2690-global-context-tls
Merge in DNS/adguard-home from AGDNS-2690-global-context-tls to master

Squashed commit of the following:

commit ae1d9e6f3f3b8abefbc5e776eb256577f7fbbb0f
Merge: 6f30f488a bf9be98c7
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Mar 10 18:15:24 2025 +0300

    Merge branch 'master' into AGDNS-2690-global-context-tls

commit 6f30f488aa2305e518000dc6c1028ede83bf1cc6
Merge: baa187ab0 66fba942c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Mar 10 15:08:47 2025 +0300

    Merge branch 'master' into AGDNS-2690-global-context-tls

commit baa187ab0b6db7f41e49dece7b4d0430409e7cae
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Mar 10 15:08:39 2025 +0300

    home: imp docs

commit 96a09389c5049a84bb30ed285cc5e1df9aaa438f
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Mar 6 20:15:05 2025 +0300

    home: imp docs

commit 1cd007707af4a7a5160c8fe21b20b84543d59e5a
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Mar 6 18:54:07 2025 +0300

    home: imp docs

commit ad3d2b6616c2c3aba566a2158ffc597e5802929f
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 4 19:38:45 2025 +0300

    home: global context tls
2025-03-10 18:24:41 +03:00
Eugene Burkov
bf9be98c71 Pull request 2357: 7555-fix-ra-packets
Merge in DNS/adguard-home from 7555-fix-ra-packets to master

* commit '0ed2cd04b22a7b447380ee863bc908603720877d':
  dhcpd: imp docs
  all: fix changelog
  dhcpd: imp docs
  all: imp code & test, log changes
  Change comment message to match implementation
  dhcpd: Fix slaac configuration which was not working due to invalid icmp packet
2025-03-10 16:30:15 +03:00
Eugene Burkov
0ed2cd04b2 Merge branch 'master' into 7555-fix-ra-packets 2025-03-10 16:17:54 +03:00
Ildar Kamalov
66fba942c8 Pull request: ADG-9775 fix disabled button for autofilled login form
Merge in DNS/adguard-home from ADG-9775 to master

Squashed commit of the following:

commit 3544ba8d463d80c69285fba4af1332e80cd2f0c8
Merge: 9631f6c4d 7f9cef948
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Mar 10 13:55:27 2025 +0300

    Merge branch 'master' into ADG-9775

commit 9631f6c4d4e32a5f241ff30b0db9f51fcc7635dc
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Mar 10 12:30:25 2025 +0300

    add changelog

commit 49fa7fe189f69758011cbdf8f78744bef5fcd0a0
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Mar 7 17:02:45 2025 +0300

    ADG-9775 fix disabled button for autofilled login form
2025-03-10 14:25:56 +03:00
Eugene Burkov
7f9cef948c Pull request 2358: Update all
Merge in DNS/adguard-home from upd-all to master

Squashed commit of the following:

commit fb5e87e0cb5617d031a2dac932304917722b1a89
Merge: af4ef937e 64994c7fc
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Mar 7 18:30:39 2025 +0300

    Merge branch 'master' into upd-all

commit af4ef937ee9ae1046cda083a4b0cb6b41ca3dc8c
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Mar 7 12:50:48 2025 +0300

    all: log changes, revert trackers

commit ca1197dc69bb845daa5ec3f25d58d995f3d330ef
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Mar 6 19:33:24 2025 +0300

    client: upd i18n

commit d6aa69668633afcabdf956a3e82b920077ddbc75
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Mar 6 19:24:57 2025 +0300

    client: upd vetted filters, companiesdb, blocked services

commit ed6f706c8eafe248b1e851bb0b123f1c46795414
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Mar 6 19:20:32 2025 +0300

    ipset: add bench results

commit 89c1fbe257163aebcddd1abf85b8e4d87536e0ef
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Mar 6 19:14:34 2025 +0300

    all: upd go, tools
2025-03-07 18:51:31 +03:00
Eugene Burkov
3be90f7b75 Merge branch 'master' into 7555-fix-ra-packets 2025-03-07 18:32:14 +03:00
Ildar Kamalov
64994c7fcb Pull request: ADG-9715 formatting of elapsed times less than one millisecond
Merge in DNS/adguard-home from ADG-9715 to master

Squashed commit of the following:

commit d972608ad5429810e0f5ad430f11e037cd05ee40
Author: Ildar Kamalov <ik@adguard.com>
Date:   Thu Mar 6 12:46:29 2025 +0300

    ADG-9715 formatting of elapsed times less than one millisecond
2025-03-07 16:42:37 +03:00
Eugene Burkov
4b0db6d397 dhcpd: imp docs 2025-03-07 13:01:34 +03:00
Eugene Burkov
777b310a4b all: fix changelog 2025-03-06 16:12:10 +03:00
Eugene Burkov
148a407f6c Merge branch 'master' into 7555-fix-ra-packets 2025-03-06 16:03:25 +03:00
Eugene Burkov
ed947a048e dhcpd: imp docs 2025-03-06 15:54:02 +03:00
Eugene Burkov
74640fb06c all: imp code & test, log changes 2025-03-06 15:15:06 +03:00
Stanislav Chzhen
0d2163c1d6 Pull request 2355: AGDNS-2686-client-manager-clock
Merge in DNS/adguard-home from AGDNS-2686-client-manager-clock to master

Squashed commit of the following:

commit 1d3cafa7f1036a72b766feaee1db00398e51c364
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Mar 4 20:52:39 2025 +0300

    all: client manager clock
2025-03-04 21:18:22 +03:00
Stanislav Chzhen
318bd2901a Pull request 2346: AGDNS-2686-client-upstream-manager
Merge in DNS/adguard-home from AGDNS-2686-client-upstream-manager to master

Squashed commit of the following:

commit 563cb583f01c26434fa04d0e37dcbe2ba15c0912
Merge: f4b0caf5c 61fe269cb
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Mar 3 19:07:35 2025 +0300

    Merge branch 'master' into AGDNS-2686-client-upstream-manager

commit f4b0caf5c8bc48ee8be97f031cd1aa1399eb461c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Feb 27 21:52:51 2025 +0300

    client: imp docs

commit e7d74931b1cc9b62eeadbe1168ae5781d57d6c73
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Feb 26 21:44:04 2025 +0300

    client: imp code

commit 1cba38c1bc3b6b5afb7829c230c4e831f789647e
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Feb 26 18:06:17 2025 +0300

    client: fix typo

commit 65b6b1e8c0fde47f367c428a78fefc4c63bc45f9
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Feb 26 17:52:02 2025 +0300

    all: imp code, docs

commit ed158ef09fc26bc9c57c91dbfa04d89fede583d0
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Feb 26 14:34:50 2025 +0300

    client: imp code

commit ab897f64c8751ea158408521116d5b689e6d39a9
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Feb 25 18:26:16 2025 +0300

    all: upd chlog

commit a2c30e3ede6fb61f6d23fd392cc3035dc96f77af
Merge: bdb08ee0e d8ce5b453
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Feb 25 17:40:32 2025 +0300

    Merge branch 'master' into AGDNS-2686-client-upstream-manager

commit bdb08ee0e6122de727f2749a44f5df7e29d0eee2
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Feb 25 17:16:31 2025 +0300

    all: imp tests

commit 00f0eb60474a2297567acf5a3a27e8b5c2d99229
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Feb 20 21:37:58 2025 +0300

    all: imp code, docs

commit 13934176636dd70a17e53bc1956d6cf51602760a
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Feb 19 15:58:11 2025 +0300

    all: client upstream manager
2025-03-03 19:19:46 +03:00
Stanislav Chzhen
61fe269cb8 Pull request 2352: AGDNS-2690-signal-handler
Merge in DNS/adguard-home from AGDNS-2690-signal-handler to master

Squashed commit of the following:

commit b6822142312ac814e7c206218bb079a4f364192a
Merge: f597ea1fd 8b2ab8ea8
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Mar 3 18:10:27 2025 +0300

    Merge branch 'master' into AGDNS-2690-signal-handler

commit f597ea1fd27c1153f2b08a339fd17361807627e6
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Feb 27 20:45:55 2025 +0300

    all: fix logger

commit 582e315ecac45e49e156bddb5755f5fb7114111c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Feb 27 18:40:57 2025 +0300

    home: imp code

commit 1e0f48d32a654b71a9c0a9e3cf3afceab00bc7e5
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Feb 27 15:26:19 2025 +0300

    home: imp docs

commit aa43bbc1b20392429d606de5cfd1b1fed940755a
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Feb 24 17:24:33 2025 +0300

    home: signal handler
2025-03-03 18:23:01 +03:00
Ildar Kamalov
8b2ab8ea87 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
2025-02-26 19:37:52 +03:00
Ainar Garipov
0389515ee3 Pull request 2351: 7583-clients-format-2
Updates #7583.

* commit 'cd5dd1eb167d9bca014b2ff45f6826e7969d7b5c':
  refactor: fix conditional check for content formatting in statistics column
2025-02-26 14:31:49 +03:00
Ainar Garipov
cd5dd1eb16 Merge branch 'master' into 7583-clients-format-2 2025-02-24 16:42:06 +03:00
Ainar Garipov
d8ce5b453c Pull request 2349: 7583-clients-format
Updates #7583.

* commit 'e9d4e76828261622dfb424c760246a21b73040e8':
  all: upd chlog
  refactor: Format value requests_count on client table
  refactor: Format value clientstats
2025-02-24 16:17:15 +03:00
Ainar Garipov
782a1a9820 Pull request 2350: 7550-riscv-install
Updates #7550.

* commit '54cc8f506f660e97a8b9e3cd370e123d73986de4':
  correct indention in install.sh
  add riscv64 to install.sh
2025-02-20 21:12:55 +03:00
Ainar Garipov
54cc8f506f Merge branch 'master' into 7550-riscv-install 2025-02-20 20:37:56 +03:00
bankjirapan
64fe768725 refactor: fix conditional check for content formatting in statistics column 2025-02-20 23:07:59 +07:00
Ainar Garipov
e9d4e76828 all: upd chlog 2025-02-20 18:55:24 +03:00
Ainar Garipov
4d2eba0f29 Merge branch 'master' into 7583-clients-format 2025-02-20 18:54:03 +03:00
Ainar Garipov
4c65b03844 Pull request 2348: upd-chlog
Merge in DNS/adguard-home from upd-chlog to master

Squashed commit of the following:

commit 3937386d8afdf5e04cddec5e0bd5ac47dad7034d
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Feb 20 18:14:30 2025 +0300

    all: upd chlog
2025-02-20 18:26:53 +03:00
Stanislav Chzhen
1e0873aa71 Pull request 2347: AGDNS-2690-global-context
Merge in DNS/adguard-home from AGDNS-2690-global-context to master

Squashed commit of the following:

commit 58d5999e5d9112b3391f988ed76e87eff2919d6b
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Feb 19 18:51:41 2025 +0300

    home: imp naming

commit cfb371df59c816be1022d499cc41ffaf2b72d124
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Feb 19 18:42:52 2025 +0300

    home: global context
2025-02-19 19:02:56 +03:00
Ainar Garipov
a5b073d070 Pull request 2344: fix-chlog
Merge in DNS/adguard-home from fix-chlog to master

Squashed commit of the following:

commit 3f209c99936f40ec67acd70e64f53368adac83dd
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Tue Feb 18 19:45:25 2025 +0300

    all: fix chlog
2025-02-18 19:54:36 +03:00
bankjirapan
84d72cb842 refactor: Format value requests_count on client table 2025-01-21 14:45:31 +07:00
bankjirapan
acbad67f47 refactor: Format value clientstats 2025-01-21 14:16:22 +07:00
dervomsee
0211a952ea correct indention in install.sh 2025-01-08 08:10:52 +01:00
dervomsee
f3161d0c05 add riscv64 to install.sh 2025-01-05 22:08:45 +01:00
Gunjan Gupta
0c72cde4c3 Change comment message to match implementation
Signed-off-by: Gunjan Gupta <viraniac@gmail.com>
2025-01-04 01:50:00 +05:30
Gunjan Gupta
b1657c2b2a dhcpd: Fix slaac configuration which was not working due to invalid icmp packet
Signed-off-by: Gunjan Gupta <viraniac@gmail.com>
2025-01-04 01:12:28 +05:30
175 changed files with 8757 additions and 11380 deletions

View File

@@ -1,8 +1,8 @@
'name': 'build'
'env':
'GO_VERSION': '1.23.6'
'NODE_VERSION': '16'
'GO_VERSION': '1.24.1'
'NODE_VERSION': '18'
'on':
'push':

View File

@@ -1,7 +1,7 @@
'name': 'lint'
'env':
'GO_VERSION': '1.23.6'
'GO_VERSION': '1.24.1'
'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

@@ -9,15 +9,48 @@ The format is based on [*Keep a Changelog*](https://keepachangelog.com/en/1.0.0/
<!--
## [v0.108.0] TBA
## [v0.107.57] - 2025-02-10 (APPROX.)
## [v0.107.58] - 2025-03-11 (APPROX.)
See also the [v0.107.57 GitHub milestone][ms-v0.107.57].
See also the [v0.107.58 GitHub milestone][ms-v0.107.58].
[ms-v0.107.57]: https://github.com/AdguardTeam/AdGuardHome/milestone/92?closed=1
[ms-v0.107.58]: https://github.com/AdguardTeam/AdGuardHome/milestone/93?closed=1
NOTE: Add new changes BELOW THIS COMMENT.
-->
### Added
- Client id and DNS record type to the filtering check form.
### Security
- Go version has been updated to prevent the possibility of exploiting the Go vulnerabilities fixed in [1.24.1][go-1.24.1].
### Fixed
- Invalid ICMPv6 Router Advertisement messages ([#7547]).
- Disabled button for autofilled login form.
- Formatting of elapsed times less than one millisecond.
- Changes to global upstream DNS settings not applying to custom client upstream configurations.
- The formatting of large numbers in the clients tables on the *Client settings* page ([#7583]).
[#7547]: https://github.com/AdguardTeam/AdGuardHome/issues/7547
[#7583]: https://github.com/AdguardTeam/AdGuardHome/issues/7583
[go-1.24.1]: https://groups.google.com/g/golang-announce/c/4t3lzH3I0eI
<!--
NOTE: Add new changes ABOVE THIS COMMENT.
-->
## [v0.107.57] - 2025-02-20
See also the [v0.107.57 GitHub milestone][ms-v0.107.57].
### Security
- Go version has been updated to prevent the possibility of exploiting the Go vulnerabilities fixed in [1.23.6][go-1.23.6].
@@ -28,21 +61,19 @@ NOTE: Add new changes BELOW THIS COMMENT.
### Changed
- The *Fastest IP adddress* upstream mode now collects statistics for the all upstream DNS servers.
- The *Fastest IP address* upstream mode now correctly collects statistics for all upstream DNS servers.
### Fixed
- The hostnames of DHCP clients not being shown in the *Top clients* table on the dashboard ([#7627]).
- The formatting of large numbers in the upstream table and query log ([#7590]).
[#7590]: https://github.com/AdguardTeam/AdGuardHome/issues/7590
[#7627]: https://github.com/AdguardTeam/AdGuardHome/issues/7627
[go-1.23.6]: https://groups.google.com/g/golang-announce/c/xU1ZCHUZw3k
<!--
NOTE: Add new changes ABOVE THIS COMMENT.
-->
[go-1.23.6]: https://groups.google.com/g/golang-announce/c/xU1ZCHUZw3k
[ms-v0.107.57]: https://github.com/AdguardTeam/AdGuardHome/milestone/92?closed=1
## [v0.107.56] - 2025-01-23
@@ -3012,11 +3043,12 @@ See also the [v0.104.2 GitHub milestone][ms-v0.104.2].
[ms-v0.104.2]: https://github.com/AdguardTeam/AdGuardHome/milestone/28?closed=1
<!--
[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.57...HEAD
[v0.107.57]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.56...v0.107.57
[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.58...HEAD
[v0.107.58]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.57...v0.107.58
-->
[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.56...HEAD
[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.57...HEAD
[v0.107.57]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.56...v0.107.57
[v0.107.56]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.55...v0.107.56
[v0.107.55]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.54...v0.107.55
[v0.107.54]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.53...v0.107.54

View File

@@ -27,13 +27,12 @@ DIST_DIR = dist
GOAMD64 = v1
GOPROXY = https://proxy.golang.org|direct
GOTELEMETRY = off
GOTOOLCHAIN = go1.23.6
GOTOOLCHAIN = go1.24.1
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,8 +7,8 @@
# Make sure to sync any changes with the branch overrides below.
'variables':
'channel': 'edge'
'dockerFrontend': 'adguard/home-js-builder:2.0'
'dockerGo': 'adguard/go-builder:1.23.6--1'
'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.24.1--1'
'stages':
- 'Build frontend':
@@ -277,8 +277,8 @@
# need to build a few of these.
'variables':
'channel': 'beta'
'dockerFrontend': 'adguard/home-js-builder:2.0'
'dockerGo': 'adguard/go-builder:1.23.6--1'
'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.24.1--1'
# release-vX.Y.Z branches are the branches from which the actual final
# release is built.
- '^release-v[0-9]+\.[0-9]+\.[0-9]+':
@@ -293,5 +293,5 @@
# are the ones that actually get released.
'variables':
'channel': 'release'
'dockerFrontend': 'adguard/home-js-builder:2.0'
'dockerGo': 'adguard/go-builder:1.23.6--1'
'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.24.1--1'

View File

@@ -5,8 +5,8 @@
'key': 'AHBRTSPECS'
'name': 'AdGuard Home - Build and run tests'
'variables':
'dockerFrontend': 'adguard/home-js-builder:2.0'
'dockerGo': 'adguard/go-builder:1.23.6--1'
'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.24.1--1'
'channel': 'development'
'stages':
@@ -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'
'dockerGo': 'adguard/go-builder:1.23.6--1'
'dockerFrontend': 'adguard/home-js-builder:2.1-bullseye'
'dockerGo': 'adguard/go-builder:1.24.1--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

@@ -620,6 +620,10 @@
"check_cname": "CNAME: {{cname}}",
"check_reason": "Reason: {{reason}}",
"check_service": "Service name: {{service}}",
"check_hostname": "Hostname or domain name",
"check_client_id": "Client identifier (ClientID or IP address)",
"check_enter_client_id": "Enter client identifier",
"check_dns_record": "Select DNS record type",
"service_name": "Service name",
"check_not_found": "Not found in your filter lists",
"client_confirm_block": "Are you sure you want to block the client \"{{ip}}\"?",

View File

@@ -327,10 +327,10 @@
"rate_limit_whitelist_placeholder": "Voer één IP-adres per regel in",
"blocking_ipv4_desc": "IP-adres dat moet worden teruggegeven voor een geblokkeerd A-verzoek",
"blocking_ipv6_desc": "IP-adres dat moet worden teruggegeven voor een geblokkeerd A-verzoek",
"blocking_mode_default": "Standaard: Reageer met een nul IP adres (0.0.0.0 for A; :: voor AAAA) wanneer geblokkeerd door een Adblock-type regel; reageer met het IP-adres dat is opgegeven in de regel wanneer geblokkeerd door een /etc/hosts type regel",
"blocking_mode_default": "Standaard: Reageer met een nul IP-adres (0.0.0.0 for A; :: voor AAAA) wanneer geblokkeerd door een Adblock-type regel; reageer met het IP-adres dat is opgegeven in de regel wanneer geblokkeerd door een /etc/hosts type regel",
"blocking_mode_refused": "REFUSED: Antwoorden met REFUSED code",
"blocking_mode_nxdomain": "NXDOMAIN: Reageer met NXDOMAIN code",
"blocking_mode_null_ip": "Nul IP: Reageer met een nul IP address (0.0.0.0 voor A; :: voor AAAA)",
"blocking_mode_null_ip": "Nul IP: Reageer met een nul IP-adres (0.0.0.0 voor A; :: voor AAAA)",
"blocking_mode_custom_ip": "Aangepast IP: Reageer met een handmatige ingesteld IP adres",
"theme_auto": "Automatisch",
"theme_light": "Licht",

View File

@@ -40,11 +40,11 @@
"dhcp_ipv4_settings": "DHCP IPv4 Ayarları",
"dhcp_ipv6_settings": "DHCP IPv6 Ayarları",
"form_error_required": "Gerekli alan",
"form_error_ip4_format": "Geçersiz IPv4 adresi",
"form_error_ip4_gateway_format": "Geçersiz ağ geçidi IPv4 adresi",
"form_error_ip6_format": "Geçersiz IPv6 adresi",
"form_error_ip_format": "Geçersiz IP adresi",
"form_error_mac_format": "Geçersiz MAC adresi",
"form_error_ip4_format": "IPv4 adresi geçersiz",
"form_error_ip4_gateway_format": "Ağ geçidi IPv4 adresi geçersiz",
"form_error_ip6_format": "IPv6 adresi geçersiz",
"form_error_ip_format": "IP adresi geçersiz",
"form_error_mac_format": "MAC adresi geçersiz",
"form_error_client_id_format": "İstemci Kimliği yalnızca sayılar, küçük harfler ve kısa çizgiler içermelidir",
"form_error_server_name": "Sunucu adı geçersiz",
"form_error_subnet": "\"{{cidr}}\" alt ağı, \"{{ip}}\" IP adresini içermiyor",
@@ -68,7 +68,7 @@
"ip": "IP",
"dhcp_table_hostname": "Ana makine Adı",
"dhcp_table_expires": "Bitiş tarihi",
"dhcp_warning": "DHCP sunucusunu yine de etkinleştirmek istiyorsanız, ağınızda başka aktif DHCP sunucusu olmadığından emin olun, aksi takdirde ağa bağlı cihazların internet bağlantısı kesilebilir!",
"dhcp_warning": "DHCP sunucusunu yine de etkinleştirmek istiyorsanız, ağınızda başka bir aktif DHCP sunucusu olmadığından emin olun, aksi takdirde ağa bağlı cihazların internet bağlantısı kesilebilir!",
"dhcp_error": "AdGuard Home, ağda başka bir etkin DHCP sunucusu olup olmadığını belirleyemedi",
"dhcp_static_ip_error": "DHCP sunucusunu kullanmak için sabit bir IP adresi ayarlanmalıdır. AdGuard Home, bu ağ arayüzünün sabit bir IP adresi kullanılarak yapılandırılıp yapılandırılmadığını belirleyemedi. Lütfen sabit IP adresini elle ayarlayın.",
"dhcp_dynamic_ip_found": "Sisteminiz, <0>{{interfaceName}}</0> arayüzü için dinamik IP adresi yapılandırması kullanıyor. DHCP sunucusunu kullanmak için sabit bir IP adresi ayarlanmalıdır. Geçerli olan IP adresiniz <0>{{ipAddress}}</0>. \"DHCP sunucusunu etkinleştir\" düğmesine basarsanız, AdGuard Home bu IP adresini otomatik bir şekilde sabit olarak ayarlayacaktır.",
@@ -147,13 +147,13 @@
"average_upstream_response_time": "Ortalama üst kaynak yanıt süresi",
"response_time": "Yanıt süresi",
"average_processing_time_hint": "Bir DNS isteğinin milisaniye cinsinden ortalama işlem süresi",
"block_domain_use_filters_and_hosts": "Filtre ve hosts dosyalarını kullanarak alan adlarını engelle",
"block_domain_use_filters_and_hosts": "Filtre ve ana bilgisayar dosyalarını kullanarak alan adlarını engelle",
"filters_block_toggle_hint": "<a>Filtreler</a> ayarlarında engelleme kuralları oluşturabilirsiniz.",
"use_adguard_browsing_sec": "AdGuard gezinti koruması web hizmetini kullan",
"use_adguard_browsing_sec_hint": "AdGuard Home, alan adının gezinti koruması web hizmeti tarafından engellenip engellenmediğini kontrol eder. Kontrolü gerçekleştirmek için gizlilik dostu arama API'sini kullanır: sunucuya yalnızca SHA256 karma alan adının kısa bir ön eki gönderilir.",
"use_adguard_parental": "AdGuard ebeveyn denetimi web hizmetini kullan",
"use_adguard_parental_hint": "AdGuard Home, alan adının yetişkin içerik bulundurup bulundurmadığını kontrol eder. Gezinti koruması web hizmeti ile kullandığımız aynı gizlilik dostu API'yi kullanır.",
"enforce_safe_search": "Güvenli Aramayı kullan",
"enforce_safe_search": "Güvenli aramayı kullan",
"enforce_save_search_hint": "AdGuard Home, şu arama motorlarında güvenli aramayı uygular: Google, YouTube, Bing, DuckDuckGo, Ecosia, Yandex, Pixabay.",
"no_servers_specified": "Sunucu belirtilmedi",
"general_settings": "Genel ayarlar",
@@ -294,6 +294,9 @@
"blocked_response_ttl": "Engellenen yanıt kullanım süresi",
"blocked_response_ttl_desc": "İstemcilerin filtrelenmiş bir yanıtı kaç saniye süreyle önbelleğe alması gerektiğini belirtir",
"form_enter_blocked_response_ttl": "Engellenen yanıt kullanım süresini girin (saniye)",
"upstream_timeout": "Üst kaynak zaman aşımı",
"upstream_timeout_desc": "Üst kaynak sunucusundan yanıt almak için kaç saniye bekleneceğini belirtir",
"form_enter_upstream_timeout": "Üst kaynak sunucusu zaman aşımı süresini saniye cinsinden girin",
"dnscrypt": "DNSCrypt",
"dns_over_https": "DNS-over-HTTPS",
"dns_over_tls": "DNS-over-TLS",
@@ -308,7 +311,7 @@
"form_enter_rate_limit": "Sıklık limitini girin",
"rate_limit": "Sıklık limiti",
"edns_enable": "EDNS istemci alt ağını etkinleştir",
"edns_cs_desc": "Kaynak yönü isteklerine EDNS İstemci Alt Ağı seçeneğini (ECS) ekleyin ve istemciler tarafından gönderilen değerleri sorgu günlüğüne kaydedin.",
"edns_cs_desc": "Üst sunucu isteklerine ECS (EDNS İstemci Alt Ağı) seçeneğini ekler ve istemciler tarafından gönderilen değerleri sorgu günlüğünde kaydeder.",
"edns_use_custom_ip": "EDNS için özel IP kullan",
"edns_use_custom_ip_desc": "EDNS için özel IP kullanımına izin ver",
"rate_limit_desc": "İstemci başına izin verilen saniyedeki istek sayısı. 0 olarak ayarlamak, sınır olmadığı anlamına gelir.",
@@ -342,17 +345,17 @@
"unknown_filter": "Bilinmeyen filtre {{filterId}}",
"known_tracker": "Bilinen izleyici",
"install_welcome_title": "AdGuard Home'a hoş geldiniz!",
"install_welcome_desc": "AdGuard Home, ağ genelinde reklamları ve izleyicileri engelleyen bir DNS sunucusudur. Tüm ağınızı ve tüm cihazlarınızı kontrol etmenizi sağlar, istemci tarafında herhangi bir program kullanmanıza gerek duymaz.",
"install_welcome_desc": "AdGuard Home, ağ genelinde reklam ve izleyici engelleyen bir DNS sunucusudur. Tüm ağınızı ve cihazlarınızı kontrol etmenizi sağlar ve istemci tarafında ek bir yazılım kullanmanıza gerek duymaz.",
"install_settings_title": "Yönetici Web Arayüzü",
"install_settings_listen": "Dinleme arayüzü",
"install_settings_port": "Bağlantı noktası",
"install_settings_interface_link": "AdGuard Home yönetici web arayüzünüz aşağıdaki adreslerde bulunacaktır:",
"install_settings_interface_link": "AdGuard Home yönetici web arayüzüne aşağıdaki adreslerden erişebilirsiniz:",
"form_error_port": "Geçerli bir bağlantı noktası değeri girin",
"install_settings_dns": "DNS sunucusu",
"install_settings_dns_desc": "Aşağıdaki adreslerde DNS sunucusunu kullanmak için cihazlarınızı veya yönlendiricinizi yapılandırmanız gerekir:",
"install_settings_dns_desc": "Cihazlarınızı veya yönlendiricinizi aşağıdaki adreslerdeki DNS sunucusunu kullanacak şekilde yapılandırmanız gerekir:",
"install_settings_all_interfaces": "Tüm arayüzler",
"install_auth_title": "Kimlik Doğrulama",
"install_auth_desc": "AdGuard Home yönetim web arayüzü için şifre doğrulaması yapılandırılmalıdır. AdGuard Home'a yalnızca yerel ağınızdan erişilebilir olsa bile, onu sınırsız erişimden korumak yine de önemlidir.",
"install_auth_desc": "AdGuard Home yönetici web arayüzüne parola ile kimlik doğrulama yapılandırılmalıdır. AdGuard Home yalnızca yerel ağınızdan erişilebilir olsa bile, yine de yetkisiz erişime karşı korunması önemlidir.",
"install_auth_username": "Kullanıcı adı",
"install_auth_password": "Parola",
"install_auth_confirm": "Parolayı onayla",
@@ -366,10 +369,10 @@
"install_devices_router": "Yönlendirici",
"install_devices_router_desc": "Bu kurulum, ev yönlendiricinize bağlı tüm cihazları otomatik olarak kapsar ve her birini elle yapılandırmanıza gerek yoktur.",
"install_devices_address": "AdGuard Home DNS sunucusu aşağıdaki adresleri dinliyor",
"install_devices_router_list_1": "Yönlendiricinizin ayarlarına gidin. Genellikle tarayıcınızdan http://192.168.0.1/ veya http://192.168.1.1/ gibi bir URL aracılığıyla erişebilirsiniz. Bir parola girmeniz istenebilir. Hatırlamıyorsanız, genellikle yönlendiricinin üzerindeki bir düğmeye basarak parolayı sıfırlayabilirsiniz, ancak bu işlemin seçilmesi durumunda yüksek ihtimalle tüm yönlendirici yapılandırmasını kaybedeceğinizi unutmayın. Yönlendiricinizin kurulumu için bir uygulama gerekiyorsa, lütfen uygulamayı telefonunuza veya PC'nize yükleyin ve yönlendiricinin ayarlarına erişmek için kullanın.",
"install_devices_router_list_1": "Yönlendiricinizin ayarlarına gidin. Genellikle, tarayıcınızdan http://192.168.0.1/ veya http://192.168.1.1/ gibi bir URL üzerinden erişebilirsiniz. Giriş yaparken bir parola girmeniz istenebilir. Parolanızı hatırlamıyorsanız, genellikle yönlendiricinin üzerindeki bir düğmeye basarak parolayı sıfırlayabilirsiniz, ancak bu işlemi seçerseniz yönlendiricinin tüm yapılandırmasını kaybedebileceğinizi unutmayın. Yönlendiricinizin kurulumu için bir uygulama gerekiyorsa, lütfen uygulamayı telefonunuza veya bilgisayarınıza yükleyin ve yönlendiricinin ayarlarına erişmek için bu uygulamayı kullanın.",
"install_devices_router_list_2": "DHCP/DNS ayarlarını bulun. DNS satırlarını arayın, genelde iki veya üç tanedir, üç rakam girilebilen dört ayrı grup içeren satırdır.",
"install_devices_router_list_3": "AdGuard Home sunucu adreslerinizi oraya girin.",
"install_devices_router_list_4": "Bazı yönlendirici türlerinde özel bir DNS sunucusu ayarlanamaz. Bu durumda, AdGuard Home'u <0>DHCP sunucusu</0> olarak ayarlamak yardımcı olabilir. Aksi takdirde, yönlendirici modeliniz için DNS sunucularını nasıl ayarlayacağınız konusunda yönlendirici kılavuzuna bakmalısınız.",
"install_devices_router_list_4": "Bazı yönlendirici türlerinde özel bir DNS sunucusu yapılandırılamaz. Bu durumda, AdGuard Home'u bir <0>DHCP sunucusu</0> olarak yapılandırmak yardımcı olabilir. Aksi takdirde, yönlendirici modelinizde DNS sunucularını nasıl özelleştireceğinizi öğrenmek için yönlendirici kılavuzunu kontrol etmelisiniz.",
"install_devices_windows_list_1": "Başlat menüsünden veya Windows araması aracılığıyla Denetim Masası'nıın.",
"install_devices_windows_list_2": "Ağ ve İnternet kategorisine girin ve ardından Ağ ve Paylaşım Merkezi'ne girin.",
"install_devices_windows_list_3": "Sol panelde \"Bağdaştırıcı ayarlarını değiştirin\" öğesine tıklayın.",
@@ -389,7 +392,7 @@
"install_devices_ios_list_2": "Sol menüde bulunan Wi-Fi bölümüne girin (telefon ağlar için özel DNS sunucusu ayarlanamaz).",
"install_devices_ios_list_3": "O anda aktif olan ağın adına dokunun.",
"install_devices_ios_list_4": "DNS alanına AdGuard Home sunucunuzun adreslerini girin.",
"get_started": "Başlayın",
"get_started": "Başla",
"next": "Sonraki",
"open_dashboard": "Panoyu Aç",
"install_saved": "Başarıyla kaydedildi",
@@ -452,14 +455,14 @@
"settings_global": "Genel",
"settings_custom": "Özel",
"table_client": "İstemci",
"table_name": "AdAdı",
"table_name": "Ad",
"save_btn": "Kaydet",
"client_add": "İstemci Ekle",
"client_new": "Yeni İstemci",
"client_edit": "İstemciyi Düzenle",
"client_identifier": "Tanımlayıcı",
"ip_address": "IP adresi",
"client_identifier_desc": "İstemciler IP adresleri, CIDR, MAC adresleri veya ClientID (DoT/DoH/DoQ için kullanılabilir) ile tanımlanabilir. İstemcileri nasıl tanımlayacağınız hakkında daha fazla bilgiyi <0>buradan</0> edinebilirsiniz.",
"client_identifier_desc": "İstemciler, IP adresi, CIDR, MAC adresi veya ClientID (DoT/DoH/DoQ için kullanılabilir) ile tanımlanabilir. İstemcileri nasıl tanımlayacağınız hakkında daha fazla bilgiye <0>buradan</0> ulaşabilirsiniz.",
"form_enter_ip": "IP girin",
"form_enter_subnet_ip": "\"{{cidr}}\" alt ağına bir IP adresi girin",
"form_enter_mac": "MAC adresi girin",
@@ -476,7 +479,7 @@
"client_confirm_delete": "\"{{key}}\" istemcisini silmek istediğinizden emin misiniz?",
"list_confirm_delete": "Bu listeyi silmek istediğinizden emin misiniz?",
"auto_clients_title": "Çalışma zamanı istemcileri",
"auto_clients_desc": "AdGuard Home'u kullanan veya kullanabilecek cihazların IP adresleri hakkında bilgiler. Bu bilgiler, hosts dosyaları, ters DNS, vb. dâhil olmak üzere çeşitli kaynaklardan toplanır.",
"auto_clients_desc": "AdGuard Home'u kullanan veya kullanabilecek cihazların IP adresleri hakkında bilgiler. Bu bilgiler, ana bilgisayar dosyaları, ters DNS sorguları ve çeşitli diğer kaynaklardan toplanmaktadır.",
"access_title": "Erişim ayarları",
"access_desc": "AdGuard Home DNS sunucusu için erişim kurallarını buradan yapılandırabilirsiniz",
"access_allowed_title": "İzin verilen istemciler",
@@ -598,12 +601,12 @@
"disable_ipv6": "IPv6 adreslerinin çözümlenmesini devre dışı bırak",
"disable_ipv6_desc": "IPv6 adresleri için tüm DNS sorgularını bırakın (AAAA yazın) ve HTTPS yanıtlarından IPv6 ipuçlarını kaldırın.",
"fastest_addr": "En hızlı IP adresi",
"fastest_addr_desc": "Tüm DNS sunucularını sorgulayın ve tüm yanıtlar arasından en hızlı olan IP adresini döndürün. AdGuard Home'un tüm DNS sunucularından yanıt beklemesi gerektiği için DNS sorgularını yavaşlatır, ancak genel bağlantıyı iyileştirir.",
"fastest_addr_desc": "<b>Tüm</b> DNS sunucularından yanıt bekler, her sunucu için TCP bağlantı hızını ölçer ve en hızlı bağlantı hızına sahip sunucunun IP adresini döndürür.<br/>Bu yapılandırma, bir veya daha fazla üst kaynak sunucusu yanıt vermediğinde, DNS sorgularını önemli ölçüde yavaşlatabilir. Üst kaynak sunucularınızın kararlı olduğundan ve üst kaynak zaman aşım sürenizin düşük olduğundan emin olun.",
"autofix_warning_text": "\"Düzelt\" seçeneğine tıklarsanız, AdGuard Home, sisteminizi AdGuard Home DNS sunucusunu kullanacak şekilde yapılandırır.",
"autofix_warning_list": "Bu görevleri gerçekleştirir: <0>Sistem DNSStubListener'ı devre dışı bırakın</0> <0>DNS sunucusu adresini 127.0.0.1 olarak ayarlayın</0> <0>/etc/resolv.conf'un sembolik bağlantı hedefini /run/systemd/resolve/resolv.conf ile değiştirin<0> <0>DNSStubListener'ı durdurun (systemd çözümlenmiş hizmeti yeniden yükleyin)</0>",
"autofix_warning_result": "Sonuç olarak, sisteminizden gelen tüm DNS istekleri varsayılan olarak AdGuard Home tarafından işlenecektir.",
"tags_title": "Etiketler",
"tags_desc": "İstemciye karşılık gelen etiketleri seçebilirsiniz. Etiketleri daha kesin olarak uygulamak için filtreleme kurallarına dâhil edin. <0>Daha fazla bilgi edinin</0>.",
"tags_desc": "İstemciyi tanımlayan etiketleri seçebilirsiniz. Filtreleme kurallarına etiketleri dahil ederek daha hassas bir şekilde uygulayabilirsiniz. <0>Daha fazla bilgi edinin</0>.",
"form_select_tags": "İstemci etiketlerini seçin",
"check_title": "Filtrelemeyi denetleyin",
"check_desc": "Ana makine adının filtreleme durumunu kontrol edin.",
@@ -624,11 +627,11 @@
"client_blocked": "\"{{ip}}\" istemcisi başarıyla engellendi",
"client_unblocked": "\"{{ip}}\" istemcinin engellemesi başarıyla kaldırıldı",
"static_ip": "Sabit IP adresi",
"static_ip_desc": "AdGuard Home bir sunucudur, bu nedenle düzgün çalışması için sabit bir IP adresine ihtiyacı vardır. Aksi takdirde, yönlendiriciniz bir zaman sonra bu cihaza farklı bir IP adresi atayabilir.",
"static_ip_desc": "AdGuard Home bir sunucudur, bu nedenle düzgün çalışabilmesi için sabit bir IP adresine ihtiyaç duyar. Aksi takdirde, yönlendiriciniz bu cihaza farklı bir IP adresi atayabilir.",
"set_static_ip": "Sabit IP adresi ayarla",
"install_static_ok": "Güzel haber! Sabit IP adresi zaten yapılandırılmış",
"install_static_error": "AdGuard Home, bu ağ arayüzü için otomatik olarak yapılandıramıyor. Lütfen bunu elle nasıl yapacağınızla ilgili talimatlara bakın.",
"install_static_configure": "AdGuard Home, <0>{{ip}}</0> dinamik IP adresinin kullanıldığını tespit etti. Sabit adresiniz olarak ayarlanmasını ister misiniz?",
"install_static_configure": "AdGuard Home, <0>{{ip}}</0> sabit IP adresinin kullanıldığını tespit etti. Sabit adresiniz olarak ayarlanmasını ister misiniz?",
"confirm_static_ip": "AdGuard Home, {{ip}} adresini sabit IP adresiniz olacak şekilde yapılandırır. Devam etmek istiyor musunuz?",
"list_updated": "{{count}} liste güncellendi",
"list_updated_plural": "{{count}} liste güncellendi",
@@ -707,8 +710,8 @@
"custom_rotation_input": "Rotasyonu saat cinsinden girin",
"protection_section_label": "Koruma",
"log_and_stats_section_label": "Sorgu günlüğü ve istatistikler",
"ignore_query_log": "Sorgu günlüğünde bu istemciyi yoksay",
"ignore_statistics": "İstatistiklerde bu istemciyi yoksay",
"ignore_query_log": "Sorgu günlüğünde bu istemciyi gösterme",
"ignore_statistics": "İstatistiklerde bu istemciyi gösterme",
"schedule_services": "Hizmet engellemeyi duraklat",
"schedule_services_desc": "Hizmet engelleme filtresinin duraklatma planını yapılandırın",
"schedule_services_desc_client": "Bu istemci için hizmet engelleme filtresinin duraklatma planını yapılandırın",
@@ -742,6 +745,6 @@
"friday_short": "Cum",
"saturday_short": "Cmt",
"upstream_dns_cache_configuration": "Üst kaynak DNS önbellek yapılandırması",
"enable_upstream_dns_cache": "Bu istemcinin özel üst kaynak yapılandırması için DNS önbelleğe almayı etkinleştir",
"enable_upstream_dns_cache": "Bu istemcinin özel üst kaynak yapılandırması için DNS önbelleğini etkinleştir",
"dns_cache_size": "DNS önbellek boyutu, bayt cinsinden"
}

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,112 @@
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';
import { DNS_RECORD_TYPES } from '../../../helpers/constants';
import { Select } from '../../ui/Controls/Select';
export type FilteringCheckFormValues = {
name: string;
client_id?: string;
dns_record_type?: string;
}
const Check = (props: CheckProps) => {
const { pristine, invalid, handleSubmit } = props;
type Props = {
onSubmit?: (data: FilteringCheckFormValues) => 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: { isValid },
} = useForm<FilteringCheckFormValues>({
mode: 'onBlur',
defaultValues: {
name: '',
client_id: '',
dns_record_type: DNS_RECORD_TYPES[0],
},
});
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')}
/>
<Controller
name="name"
control={control}
rules={{ validate: validateRequiredValue }}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
label={t('check_hostname')}
data-testid="check_domain_name"
placeholder="example.com"
error={fieldState.error?.message}
/>
)}
/>
<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="client_id"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
data-testid="check_client_id"
label={t('check_client_id')}
placeholder={t('check_enter_client_id')}
error={fieldState.error?.message}
/>
)}
/>
<Controller
name="dns_record_type"
control={control}
render={({ field }) => (
<Select
{...field}
label={t('check_dns_record')}
data-testid="check_dns_record_type"
>
{DNS_RECORD_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
)}
/>
<button
className="btn btn-success btn-standard btn-large"
type="submit"
data-testid="check_domain_submit"
disabled={!isValid || processingCheck}
>
{t('check')}
</button>
{hostname && (
<>
<hr />
<Info />
</>
)}
@@ -67,4 +117,4 @@ const Check = (props: CheckProps) => {
);
};
export default reduxForm({ form: FORM_NAME.DOMAIN_CHECK })(Check);
export default Check;

View File

@@ -7,7 +7,7 @@ import PageTitle from '../ui/PageTitle';
import Examples from './Examples';
import Check from './Check';
import Check, { FilteringCheckFormValues } from './Check';
import { getTextareaCommentsHighlight, syncScroll } from '../../helpers/highlightTextareaComments';
import { COMMENT_LINE_DEFAULT_TOKEN } from '../../helpers/constants';
@@ -48,7 +48,7 @@ class CustomRules extends Component<CustomRulesProps> {
this.props.setRules(this.props.filtering.userRules);
};
handleCheck = (values: any) => {
handleCheck = (values: FilteringCheckFormValues) => {
this.props.checkHost(values);
};

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

@@ -12,7 +12,7 @@ import whoisCell from './whoisCell';
import LogsSearchLink from '../../ui/LogsSearchLink';
import { sortIp } from '../../../helpers/helpers';
import { sortIp, formatNumber } from '../../../helpers/helpers';
import { LocalStorageHelper, LOCAL_STORAGE_KEYS } from '../../../helpers/localStorageHelper';
import { TABLES_MIN_ROWS } from '../../../helpers/constants';
@@ -66,7 +66,7 @@ class AutoClients extends Component<AutoClientsProps> {
return (
<div className="logs__row">
<div className="logs__text" title={clientStats}>
<LogsSearchLink search={row.original.ip}>{clientStats}</LogsSearchLink>
<LogsSearchLink search={row.original.ip}>{formatNumber(clientStats)}</LogsSearchLink>
</div>
</div>
);

View File

@@ -12,7 +12,7 @@ import ReactTable from 'react-table';
import { getAllBlockedServices, getBlockedServices } from '../../../../actions/services';
import { initSettings } from '../../../../actions';
import { splitByNewLine, countClientsStatistics, sortIp, getService } from '../../../../helpers/helpers';
import { splitByNewLine, countClientsStatistics, sortIp, getService, formatNumber } from '../../../../helpers/helpers';
import { MODAL_TYPE, LOCAL_TIMEZONE_VALUE, TABLES_MIN_ROWS } from '../../../../helpers/constants';
import Card from '../../../ui/Card';
@@ -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;
}
@@ -300,12 +306,15 @@ const ClientsTable = ({
sortMethod: (a: any, b: any) => b - a,
minWidth: 120,
Cell: (row: any) => {
const content = CellWrap(row);
if (!row.value) {
let content = row.value;
if (typeof content === "number") {
content = formatNumber(content);
} else {
content = CellWrap(row);
}
if (!content) {
return content;
}
return <LogsSearchLink search={row.original.name}>{content}</LogsSearchLink>;
},
},

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';
@@ -522,3 +523,12 @@ export const TIME_UNITS = {
HOURS: 'hours',
DAYS: 'days',
};
export const DNS_RECORD_TYPES = [
"A", "AAAA", "AFSDB", "APL", "CAA", "CDNSKEY", "CDS", "CERT", "CNAME",
"CSYNC", "DHCID", "DLV", "DNAME", "DNSKEY", "DS", "EUI48", "EUI64",
"HINFO", "HIP", "HTTPS", "IPSECKEY", "KEY", "KX", "LOC", "MX", "NAPTR",
"NS", "NSEC", "NSEC3", "NSEC3PARAM", "OPENPGPKEY", "PTR", "RP", "RRSIG",
"SIG", "SMIMEA", "SOA", "SRV", "SSHFP", "SVCB", "TA", "TKEY",
"TLSA", "TSIG", "TXT", "URI", "ZONEMD"
];

View File

@@ -28,12 +28,6 @@ export default {
"homepage": "https://badmojr.github.io/1Hosts/",
"source": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_24.txt"
},
"1hosts_mini": {
"name": "1Hosts (mini)",
"categoryId": "general",
"homepage": "https://badmojr.github.io/1Hosts/",
"source": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_38.txt"
},
"CHN_adrules": {
"name": "CHN: AdRules DNS List",
"categoryId": "regional",

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;
@@ -671,15 +669,17 @@ export const countClientsStatistics = (ids: any, autoClients: any) => {
* @returns {string}
*/
export const formatElapsedMs = (elapsedMs: string, t: (key: string) => string) => {
const parsedElapsedMs = parseInt(elapsedMs, 10);
const parsedElapsedMs = parseFloat(elapsedMs);
if (Number.isNaN(parsedElapsedMs)) {
return elapsedMs;
}
const formattedMs = formatNumber(parsedElapsedMs);
const formattedValue = parsedElapsedMs < 1
? parsedElapsedMs.toFixed(2)
: Math.floor(parsedElapsedMs).toString();
return `${formattedMs} ${t('milliseconds_abbreviation')}`;
return `${formattedValue} ${t('milliseconds_abbreviation')}`;
};
/**

View File

@@ -1,5 +1,5 @@
{
"timeUpdated": "2025-01-13T10:04:54.031Z",
"timeUpdated": "2025-03-10T15:13:28.992Z",
"categories": {
"0": "audio_video_player",
"1": "comments",
@@ -5940,7 +5940,8 @@
"name": "Digioh",
"categoryId": 4,
"url": "https://digioh.com/",
"companyId": null
"companyId": "digioh",
"source": "AdGuard"
},
"digital.gov": {
"name": "Digital.gov",
@@ -8261,8 +8262,8 @@
},
"google_marketing": {
"name": "Google Marketing",
"categoryId": 6,
"url": "https://marketingplatform.google.com/",
"categoryId": 4,
"url": "https://marketingplatform.google.com/about/enterprise",
"companyId": "google",
"source": "AdGuard"
},
@@ -9058,6 +9059,13 @@
"url": "https://www.ippen-digital.de/",
"companyId": null
},
"id5-sync": {
"name": "ID5 Sync",
"categoryId": 4,
"url": "https://id5.io/",
"companyId": "id5-sync",
"source": "AdGuard"
},
"id_services": {
"name": "ID Services",
"categoryId": 6,
@@ -20948,6 +20956,8 @@
"wunderloop.net": "audience_science",
"12mlbe.com": "audiencerate",
"audiencesquare.com": "audiencesquare.com",
"ad.gt": "audiencesquare.com",
"audigent.com": "audiencesquare.com",
"auditude.com": "auditude",
"audtd.com": "audtd.com",
"cdn.augur.io": "augur",
@@ -21682,9 +21692,6 @@
"dtmpub.com": "dotomi",
"double.net": "double.net",
"2mdn.net": "doubleclick",
"doubleclick.net": "doubleclick",
"invitemedia.com": "doubleclick",
"doubleclick.com": "doubleclick",
"doublepimp.com": "doublepimp",
"doublepimpssl.com": "doublepimp",
"redcourtside.com": "doublepimp",
@@ -21988,6 +21995,7 @@
"cdn.foxpush.net": "foxpush",
"foxpush.com": "foxpush",
"foxtel.com.au": "foxtel",
"foxtelgroupcdn.net.au": "foxtel",
"foxydeal.com": "foxydeal_com",
"yabidos.com": "fraudlogix",
"besucherstatistiken.com": "free_counter",
@@ -22389,6 +22397,8 @@
"maps.google.es": "google_maps",
"maps.google.se": "google_maps",
"maps.gstatic.com": "google_maps",
"doubleclick.net": "google_marketing",
"invitemedia.com": "google_marketing",
"adsense.google.com": "google_marketing",
"adservice.google.ca": "google_marketing",
"adservice.google.co.in": "google_marketing",
@@ -22419,6 +22429,7 @@
"adservice.google.vg": "google_marketing",
"adtrafficquality.google": "google_marketing",
"dai.google.com": "google_marketing",
"doubleclick.com": "google_marketing",
"doubleclickbygoogle.com": "google_marketing",
"googlesyndication-cn.com": "google_marketing",
"duo.google.com": "google_meet",
@@ -22604,6 +22615,9 @@
"icuazeczpeoohx.com": "icuazeczpeoohx.com",
"id-news.net": "id-news.net",
"idcdn.de": "id-news.net",
"eu-1-id5-sync.com": "id5-sync",
"id5-sync.com": "id5-sync",
"id5.io": "id5-sync",
"cdn.id.services": "id_services",
"e-generator.com": "ideal_media",
"idealo.com": "idealo_com",
@@ -23656,6 +23670,7 @@
"blockmetrics.com": "pagefair",
"pagefair.com": "pagefair",
"pagefair.net": "pagefair",
"btloader.com": "pagefair",
"ghmedia.com": "pagescience",
"777seo.com": "paid-to-promote",
"paid-to-promote.net": "paid-to-promote",

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: 'onChange',
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,
});

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