From be27c064816a5f130fab88110c0d759c65a3027f Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Tue, 26 Aug 2025 16:01:14 +0300 Subject: [PATCH] Pull request 2452: ADG-10295 add encryption settings page Squashed commit of the following: commit e841e3a0b43fee70888ff12e16fc6ec75bf474ab Author: Ildar Kamalov Date: Tue Aug 26 15:49:58 2025 +0300 fix todo commit cf251e209b21656d4506dd02b3f3377c922ad9b5 Author: Ildar Kamalov Date: Tue Aug 26 15:48:31 2025 +0300 fix lint issue commit c47e8216ec0ceaffe955997321b08149e0fb2367 Merge: f754d7cd9 0e3692f9a Author: Ildar Kamalov Date: Tue Aug 26 15:38:36 2025 +0300 Merge branch 'ADG-10291' into ADG-10295 commit f754d7cd97dfbb4537dd84a630a37c5f94ba46c1 Merge: d56ea7d8a febcc6814 Author: Ildar Kamalov Date: Fri Aug 22 18:26:15 2025 +0300 Merge branch 'ADG-10291' into ADG-10295 commit d56ea7d8a6ddeba95776879d15e7ff047acb8548 Author: Ildar Kamalov Date: Thu Aug 21 16:34:47 2025 +0300 fix form validation commit a5fee7896324335c5d0cdfd189376a4a99362ef5 Author: Ildar Kamalov Date: Wed Aug 20 17:59:53 2025 +0300 fix form commit a2da460426c96fd3bc472df26345b30cb62bb7f1 Merge: cff3f4588 ebfa5230a Author: Ildar Kamalov Date: Fri Aug 15 17:30:28 2025 +0300 Merge branch 'ADG-10291' into ADG-10295 commit cff3f45883cea1d903bceff633f6fbcfea6e63a6 Author: Ildar Kamalov Date: Wed Aug 13 19:28:46 2025 +0300 ADG-10295 add encryption settings page commit 3f6d759b7f6542fc36b203c62b010b35f346b4e6 Author: Ildar Kamalov Date: Tue Aug 12 16:40:31 2025 +0300 fix placeholder commit 507631ab713bda633823e04fa4530b6f5d34a2c6 Author: Ildar Kamalov Date: Tue Aug 12 16:15:57 2025 +0300 use batch commit e495a5585cc58574152ca60db04dab5000717edf Author: Ildar Kamalov Date: Tue Aug 12 16:13:13 2025 +0300 review fix commit 2ba671b408b97a85d208415b708790373aaf19b0 Author: Ildar Kamalov Date: Tue Aug 12 10:45:00 2025 +0300 fix commit 6f696238a02863a92d2fc12c708eaffba68b38d0 Author: Ildar Kamalov Date: Mon Aug 11 18:38:28 2025 +0300 fix language change commit 8206f3f885be2bf0103bd9607b11153efdec7b6b Author: Ildar Kamalov Date: Mon Aug 11 14:54:58 2025 +0300 fix custom radio commit 0920a51c4ad88d8b77c1595ba878db50a493d87b Author: Ildar Kamalov Date: Mon Aug 11 14:30:26 2025 +0300 fix commit c7ca1e8eaf07cfca10dc1c23917e61e200ebf202 Author: Ildar Kamalov Date: Mon Aug 11 14:25:16 2025 +0300 fix ids commit 7ecd6426d9c3f121c1c09c1cb89d79cd80d6c9d6 Author: Ildar Kamalov Date: Mon Aug 11 13:56:29 2025 +0300 fix constant import commit 246f1277b8e1c1a627a784420c760ee15d7ff165 Author: Ildar Kamalov Date: Mon Aug 11 13:46:11 2025 +0300 fix version commit 0224f567e63605f3b64607850f4d1e68d337f3a7 Author: Ildar Kamalov Date: Mon Aug 11 13:34:29 2025 +0300 fix version commit 75c9caffbdc0273da60eb6312731b517c7204323 Author: Ildar Kamalov Date: Mon Aug 11 13:14:01 2025 +0300 lint fix commit d0cf6ad5ab18f864cac697fbb3c8485ad7297840 Author: Ildar Kamalov Date: Mon Aug 11 12:40:49 2025 +0300 lint fix commit 87eb68e519b52aad3d577f25d68f17e2ad8ed4fa Author: Ildar Kamalov Date: Mon Aug 11 12:06:35 2025 +0300 use npm commit 7df7d60ec931facb16a4d91716c82a1154612b74 Author: Ildar Kamalov Date: Mon Aug 11 11:23:00 2025 +0300 rm unused commit c285ea978ad089b25503d4f347aca0c9ce8c402b Author: Ildar Kamalov Date: Mon Aug 11 10:42:39 2025 +0300 fix commit d00f1e12b3ca3dc319d8ed9252d97ce218d44f2f Author: Ildar Kamalov Date: Mon Aug 11 09:43:35 2025 +0300 fix ... and 2 more commits --- Makefile | 2 +- client_v2/src/__locales/en.json | 60 +- .../controls/Checkbox/Checkbox.module.pcss | 6 +- .../common/controls/Input/Input.module.pcss | 4 +- client_v2/src/common/controls/Input/Input.tsx | 2 +- .../common/controls/Radio/Radio.module.pcss | 4 + .../src/common/ui/Button/Button.module.pcss | 21 + client_v2/src/common/ui/Button/Button.tsx | 2 +- .../src/common/ui/FaqTooltip/FaqTooltip.tsx | 35 + client_v2/src/common/ui/FaqTooltip/index.ts | 1 + .../common/ui/FaqTooltip/styles.module.pcss | 19 + .../common/ui/SettingsGroup/RadioGroup.tsx | 50 ++ .../common/ui/SettingsGroup/SwitchGroup.tsx | 46 ++ .../src/common/ui/SettingsGroup/index.ts | 2 + .../ui/SettingsGroup/styles.module.pcss | 40 ++ client_v2/src/components/App/index.tsx | 8 +- client_v2/src/components/Encryption/Form.tsx | 636 ++++++++++++++++++ .../Encryption/Status/CertificateStatus.tsx | 34 + .../Encryption/Status/KeyStatus.tsx | 19 + .../Encryption/Status/StatusBlock.tsx | 28 + .../Encryption/Status/ValidationStatus.tsx | 17 + .../src/components/Encryption/Status/index.ts | 3 + .../Encryption/Status/styles.module.pcss | 26 + client_v2/src/components/Encryption/index.tsx | 115 ++++ .../components/Encryption/styles.module.pcss | 22 + .../src/components/Settings/FiltersConfig.tsx | 5 +- .../IgnoredDomains/IgnoredDomains.tsx | 30 +- .../IgnoredDomains/styles.module.pcss | 14 - .../components/Settings/LogsConfig/Form.tsx | 3 +- .../src/components/Settings/Settings.tsx | 31 +- .../components/Settings/StatsConfig/Form.tsx | 4 +- client_v2/src/helpers/validators.ts | 57 +- client_v2/src/lib/theme/Form.module.pcss | 8 +- scripts/companiesdb/download.sh | 2 +- scripts/translations/main.go | 10 +- scripts/vetted-filters/main.go | 2 +- 36 files changed, 1246 insertions(+), 122 deletions(-) create mode 100644 client_v2/src/common/ui/FaqTooltip/FaqTooltip.tsx create mode 100644 client_v2/src/common/ui/FaqTooltip/index.ts create mode 100644 client_v2/src/common/ui/FaqTooltip/styles.module.pcss create mode 100644 client_v2/src/common/ui/SettingsGroup/RadioGroup.tsx create mode 100644 client_v2/src/common/ui/SettingsGroup/SwitchGroup.tsx create mode 100644 client_v2/src/common/ui/SettingsGroup/index.ts create mode 100644 client_v2/src/common/ui/SettingsGroup/styles.module.pcss create mode 100644 client_v2/src/components/Encryption/Form.tsx create mode 100644 client_v2/src/components/Encryption/Status/CertificateStatus.tsx create mode 100644 client_v2/src/components/Encryption/Status/KeyStatus.tsx create mode 100644 client_v2/src/components/Encryption/Status/StatusBlock.tsx create mode 100644 client_v2/src/components/Encryption/Status/ValidationStatus.tsx create mode 100644 client_v2/src/components/Encryption/Status/index.ts create mode 100644 client_v2/src/components/Encryption/Status/styles.module.pcss create mode 100644 client_v2/src/components/Encryption/index.tsx create mode 100644 client_v2/src/components/Encryption/styles.module.pcss diff --git a/Makefile b/Makefile index 23fc4a6d..5412d596 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ GO.MACRO = $${GO:-go} VERBOSE.MACRO = $${VERBOSE:-0} CHANNEL = development -# TODO: Update CLIENT_DIR to client_v2 for new frontend migration +# TODO(ik): Update CLIENT_DIR to client_v2 for new frontend migration CLIENT_DIR = client DEPLOY_SCRIPT_PATH = not/a/real/path DIST_DIR = dist diff --git a/client_v2/src/__locales/en.json b/client_v2/src/__locales/en.json index b5d11521..ea52611f 100644 --- a/client_v2/src/__locales/en.json +++ b/client_v2/src/__locales/en.json @@ -393,40 +393,23 @@ "next": "Next", "open_dashboard": "Open Dashboard", "install_saved": "Saved successfully", - "encryption_title": "Encryption", "encryption_desc": "Encryption (HTTPS/QUIC/TLS) support for both DNS and admin web interface", "encryption_config_saved": "Encryption configuration saved", - "encryption_server": "Server name", "encryption_server_enter": "Enter your domain name", "encryption_server_desc": "If set, AdGuard Home detects ClientIDs, responds to DDR queries, and performs additional connection validations. If not set, these features are disabled. Must match one of the DNS Names in the certificate.", "encryption_redirect": "Redirect to HTTPS automatically", "encryption_redirect_desc": "If checked, AdGuard Home will automatically redirect you from HTTP to HTTPS addresses.", - "encryption_https": "HTTPS port", "encryption_https_desc": "If HTTPS port is configured, AdGuard Home admin interface will be accessible via HTTPS, and it will also provide DNS-over-HTTPS on '/dns-query' location.", - "encryption_dot": "DNS-over-TLS port", "encryption_dot_desc": "If this port is configured, AdGuard Home will run a DNS-over-TLS server on this port.", - "encryption_doq": "DNS-over-QUIC port", "encryption_doq_desc": "If this port is configured, AdGuard Home will run a DNS-over-QUIC server on this port.", - "encryption_certificates": "Certificates", - "encryption_certificates_desc": "In order to use encryption, you need to provide a valid SSL certificates chain for your domain. You can get a free certificate on <0>{{link}} or you can buy it from one of the trusted Certificate Authorities.", "encryption_certificates_input": "Copy/paste your PEM-encoded certificates here.", "encryption_status": "Status", - "encryption_expire": "Expires", - "encryption_key": "Private key", "encryption_key_input": "Copy/paste your PEM-encoded private key for your certificate here.", "encryption_enable": "Enable Encryption (HTTPS, DNS-over-HTTPS, and DNS-over-TLS)", "encryption_enable_desc": "If encryption is enabled, AdGuard Home admin interface will work over HTTPS, and the DNS server will listen for requests over DNS-over-HTTPS and DNS-over-TLS.", - "encryption_chain_valid": "Certificate chain is valid", - "encryption_chain_invalid": "Certificate chain is invalid", - "encryption_key_valid": "This is a valid {{type}} private key", - "encryption_key_invalid": "This is an invalid {{type}} private key", - "encryption_subject": "Subject", - "encryption_issuer": "Issuer", - "encryption_hostnames": "Hostnames", "encryption_reset": "Are you sure you want to reset encryption settings?", "encryption_warning": "Warning", "encryption_plain_dns_enable": "Enable plain DNS", - "encryption_plain_dns_desc": "Plain DNS is enabled by default. You can disable it to force all devices to use encrypted DNS. To do this, you must enable at least one encrypted DNS protocol", "encryption_plain_dns_error": "To disable plain DNS, enable at least one encrypted DNS protocol", "topline_expiring_certificate": "Your SSL certificate is about to expire. Update <0>Encryption settings.", "topline_expired_certificate": "Your SSL certificate is expired. Update <0>Encryption settings.", @@ -805,5 +788,46 @@ "settings_tooltip_examples": "Examples:", "settings_notify_changes_saved": "Changes saved", "settings_notify_query_log_cleared": "Query log cleared", - "settings_notify_statistics_cleared": "Statistics cleared" + "settings_notify_statistics_cleared": "Statistics cleared", + "encryption_title": "Encryption", + "encryption_encrypted_dns": "Encrypted DNS", + "encryption_encrypted_dns_desc": "The AdGuard Home admin interface uses HTTPS. The DNS server supports DNS-over-HTTPS, DNS-over-TLS, and DNS-over-QUIC", + "encryption_plain_dns": "Plain DNS", + "encryption_plain_dns_desc": "Plain DNS is enabled by default. You can disable it to force all devices to use encrypted DNS. To do this, you must specify at least one encrypted DNS protocol", + "encryption_server": "Server name", + "encryption_server_tooltip_1": "If set, AdGuard Home will detect ClientIDs, respond to DDR queries, and perform additional connection validations. If not set, these features are disabled", + "encryption_server_tooltip_2": "Must match a DNS name in the certificate", + "encryption_https": "HTTPS port", + "encryption_https_tooltip": "This port provides HTTPS access for the AdGuard Home admin interface. It also provides DNS-over-HTTPS at the '/dns-query' location", + "encryption_dot": "DNS-over-TLS port", + "encryption_dot_tooltip": "This port is used to run a DNS-over-TLS server", + "encryption_doq": "DNS-over-QUIC port", + "encryption_doq_tooltip": "This port is used to run a DNS-over-QUIC server", + "encryption_force_redirect": "Automatically redirect from HTTP to HTTPS", + "encryption_certificates": "Certificates", + "encryption_certificates_desc": "To use encryption, you must provide a valid SSL certificate chain for your domain. You can get a free SSL certificate from letsencrypt.org or buy one from a trusted Certificate Authority", + "encryption_key": "Private key", + "encryption_disable_plain_dns": "Disable plain DNS?", + "encryption_disable_plain_dns_desc": "Devices that do not support encrypted DNS may not work properly", + "disable": "Disable", + "encryption_chain_valid": "Certificate chain is valid", + "encryption_chain_invalid": "Certificate chain is invalid", + "encryption_key_valid": "Private key is valid", + "encryption_key_type": "Encryption algorithm: %value%", + "encryption_key_invalid": "Private key is invalid", + "encryption_subject": "Subject: %value%", + "encryption_issuer": "Issuer: %value%", + "encryption_expire": "Expires: %value%", + "encryption_hostnames": "Hostnames: %value%", + "encryption_certificate_has_issues": "Certificate has issues", + "encryption_confirm_clear": "Reset encryption settings", + "encryption_confirm_clear_desc": "All encryption settings will be reset to their default values", + "reset": "Reset", + "confirm": "Confirm", + "encryption_certificate_error": "Unable to read certificate file", + "encryption_private_key_error": "Private key does not match certificate", + "encryption_certificate_name_error": "The server certificate does not include the server name %value%", + "encryption_invalid_data": "Invalid data", + "encryption_certificate_no_ip": "This certificate does not contain IP addresses. DDR and DNS-over-TLS may not work properly", + "encryption_certificate_self_signed": "This is a self-signed certificate — it may not work on all devices. If you want to use it, make sure that all your devices will accept it" } diff --git a/client_v2/src/common/controls/Checkbox/Checkbox.module.pcss b/client_v2/src/common/controls/Checkbox/Checkbox.module.pcss index 0561a769..6bce1db5 100644 --- a/client_v2/src/common/controls/Checkbox/Checkbox.module.pcss +++ b/client_v2/src/common/controls/Checkbox/Checkbox.module.pcss @@ -36,13 +36,9 @@ .label { display: grid; align-items: center; - font-size: 14px; + font-size: 16px; font-weight: var(--weight-regular); user-select: none; - - @media (min-width: 1024px) { - font-size: 16px; - } } .icon { diff --git a/client_v2/src/common/controls/Input/Input.module.pcss b/client_v2/src/common/controls/Input/Input.module.pcss index c4b148cc..f46029a9 100644 --- a/client_v2/src/common/controls/Input/Input.module.pcss +++ b/client_v2/src/common/controls/Input/Input.module.pcss @@ -99,7 +99,9 @@ } .inputLabel { - display: block; + display: flex; + align-items: center; + gap: 8px; margin-bottom: 4px; font-size: 14px; line-height: 24px; diff --git a/client_v2/src/common/controls/Input/Input.tsx b/client_v2/src/common/controls/Input/Input.tsx index 10dfc86e..5811b006 100644 --- a/client_v2/src/common/controls/Input/Input.tsx +++ b/client_v2/src/common/controls/Input/Input.tsx @@ -3,7 +3,7 @@ import cn from 'clsx'; import s from './Input.module.pcss'; -type Props = ComponentProps<'input'> & { +type Props = Omit, 'size'> & { label?: ReactNode; className?: string; innerClassName?: string; diff --git a/client_v2/src/common/controls/Radio/Radio.module.pcss b/client_v2/src/common/controls/Radio/Radio.module.pcss index 1b25b4e8..0c89e852 100644 --- a/client_v2/src/common/controls/Radio/Radio.module.pcss +++ b/client_v2/src/common/controls/Radio/Radio.module.pcss @@ -33,5 +33,9 @@ } .input:disabled + .handler .icon { + color: var(--disabled-gray-icons); +} + +.input:disabled:checked + .handler .icon { color: var(--disabled-main-button); } diff --git a/client_v2/src/common/ui/Button/Button.module.pcss b/client_v2/src/common/ui/Button/Button.module.pcss index 91585bd8..f8b92881 100644 --- a/client_v2/src/common/ui/Button/Button.module.pcss +++ b/client_v2/src/common/ui/Button/Button.module.pcss @@ -115,6 +115,27 @@ } } +.secondary-danger { + color: var(--default-danger-button); + border-color: var(--default-danger-button); + + &:hover, + &:focus { + color: var(--hovered-danger-button); + border-color: var(--hovered-danger-button); + } + + &:active { + color: var(--pressed-danger-button); + border-color: var(--pressed-danger-button); + } + + &:disabled { + color: var(--disabled-danger-button); + border-color: var(--disabled-danger-button); + } +} + .danger { color: var(--gray-0); background: var(--default-danger-button); diff --git a/client_v2/src/common/ui/Button/Button.tsx b/client_v2/src/common/ui/Button/Button.tsx index bda4f4a7..0e13cf52 100644 --- a/client_v2/src/common/ui/Button/Button.tsx +++ b/client_v2/src/common/ui/Button/Button.tsx @@ -5,7 +5,7 @@ import s from './Button.module.pcss'; export type ButtonProps = ComponentProps<'button'> & { size?: 'small' | 'medium' | 'big'; - variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'secondary-danger'; children?: ReactNode; leftAddon?: ReactNode; rightAddon?: ReactNode; diff --git a/client_v2/src/common/ui/FaqTooltip/FaqTooltip.tsx b/client_v2/src/common/ui/FaqTooltip/FaqTooltip.tsx new file mode 100644 index 00000000..1accd3b2 --- /dev/null +++ b/client_v2/src/common/ui/FaqTooltip/FaqTooltip.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode } from 'react'; +import cn from 'clsx'; + +import { Dropdown } from 'panel/common/ui/Dropdown'; +import theme from 'panel/lib/theme'; +import { Icon } from 'panel/common/ui/Icon'; + +import s from './styles.module.pcss'; + +type Props = { + text: ReactNode; + menuSize?: 'small' | 'large'; +}; + +export const FaqTooltip = ({ text, menuSize = 'small' }: Props) => { + return ( + + {text} + + } + className={s.dropdown} + position="bottomLeft" + noIcon> +
+ +
+
+ ); +}; diff --git a/client_v2/src/common/ui/FaqTooltip/index.ts b/client_v2/src/common/ui/FaqTooltip/index.ts new file mode 100644 index 00000000..1e21f015 --- /dev/null +++ b/client_v2/src/common/ui/FaqTooltip/index.ts @@ -0,0 +1 @@ +export { FaqTooltip } from './FaqTooltip'; diff --git a/client_v2/src/common/ui/FaqTooltip/styles.module.pcss b/client_v2/src/common/ui/FaqTooltip/styles.module.pcss new file mode 100644 index 00000000..404373b0 --- /dev/null +++ b/client_v2/src/common/ui/FaqTooltip/styles.module.pcss @@ -0,0 +1,19 @@ +.trigger { + cursor: pointer; + color: var(--default-gray-icons); +} + +.menu { + padding: 16px; + background-color: var(--fills-backgrounds-page-background-additional); + + @media (min-width: 768px) { + max-width: 280px; + } + + &_large { + @media (min-width: 768px) { + max-width: 340px; + } + } +} diff --git a/client_v2/src/common/ui/SettingsGroup/RadioGroup.tsx b/client_v2/src/common/ui/SettingsGroup/RadioGroup.tsx new file mode 100644 index 00000000..2c8f6520 --- /dev/null +++ b/client_v2/src/common/ui/SettingsGroup/RadioGroup.tsx @@ -0,0 +1,50 @@ +import React, { ReactNode } from 'react'; +import cn from 'clsx'; + +import { Radio } from 'panel/common/controls/Radio'; +import theme from 'panel/lib/theme'; + +import s from './styles.module.pcss'; + +type Option = { text: string; value: T }; + +type Props = { + title: string; + description?: string; + disabled?: boolean; + value: T; + options: Option[]; + onChange: (value: T) => void; + className?: string; + children?: ReactNode; + name?: string; +}; + +export const RadioGroup = ({ + title, + description, + disabled, + value, + options, + onChange, + className, + children, + name, +}: Props) => { + return ( +
+
+
+
{title}
+ {description &&
{description}
} +
+
+
+ +
+ disabled={disabled} value={value} options={options} handleChange={onChange} name={name} /> + {children} +
+
+ ); +}; diff --git a/client_v2/src/common/ui/SettingsGroup/SwitchGroup.tsx b/client_v2/src/common/ui/SettingsGroup/SwitchGroup.tsx new file mode 100644 index 00000000..56a3350f --- /dev/null +++ b/client_v2/src/common/ui/SettingsGroup/SwitchGroup.tsx @@ -0,0 +1,46 @@ +import React, { ReactNode, useRef } from 'react'; +import cn from 'clsx'; + +import { Switch } from 'panel/common/controls/Switch'; +import theme from 'panel/lib/theme'; + +import s from './styles.module.pcss'; + +type Props = { + title: string; + description?: string; + id: string; + className?: string; + checked: boolean; + onChange: (e: React.ChangeEvent) => void; + disabled?: boolean; + children?: ReactNode; +}; + +export const SwitchGroup = ({ title, description, id, className, checked, onChange, disabled, children }: Props) => { + const inputRef = useRef(null); + + const handleRowClick = () => { + if (disabled || !inputRef.current) { + return; + } + + inputRef.current?.click(); + }; + + return ( +
+
+
+
{title}
+ {description &&
{description}
} +
+
e.stopPropagation()}> + +
+
+ + {children &&
{children}
} +
+ ); +}; diff --git a/client_v2/src/common/ui/SettingsGroup/index.ts b/client_v2/src/common/ui/SettingsGroup/index.ts new file mode 100644 index 00000000..b1ed7a80 --- /dev/null +++ b/client_v2/src/common/ui/SettingsGroup/index.ts @@ -0,0 +1,2 @@ +export { RadioGroup } from './RadioGroup'; +export { SwitchGroup } from './SwitchGroup'; diff --git a/client_v2/src/common/ui/SettingsGroup/styles.module.pcss b/client_v2/src/common/ui/SettingsGroup/styles.module.pcss new file mode 100644 index 00000000..9739abd8 --- /dev/null +++ b/client_v2/src/common/ui/SettingsGroup/styles.module.pcss @@ -0,0 +1,40 @@ +.switch { + width: 100%; + padding: 8px 16px; + transition: background-color var(--t2); + border-radius: 8px; + + &:hover, + &:focus { + background-color: var(--fills-backgrounds-page-background-additional); + } +} + +.row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + cursor: pointer; + + @media (min-width: 1024px) { + gap: 40px; + } +} + +.text { + padding: 8px 0; +} + +.title { + color: var(--default-main-text); +} + +.desc { + color: var(--default-description-text); +} + +.input, +.content { + padding: 8px 0; +} diff --git a/client_v2/src/components/App/index.tsx b/client_v2/src/components/App/index.tsx index d329b967..06c8e535 100644 --- a/client_v2/src/components/App/index.tsx +++ b/client_v2/src/components/App/index.tsx @@ -8,8 +8,9 @@ import { Icons } from 'panel/common/ui/Icons'; import { Footer } from 'panel/common/ui/Footer'; import { Header } from 'panel/common/ui/Header'; import { Settings } from 'panel/components/Settings'; - import { LocalesType } from 'panel/common/intl'; +import { Encryption } from 'panel/components/Encryption'; + import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from 'panel/helpers/localStorageHelper'; import Toasts from '../Toasts'; import i18n from '../../i18n'; @@ -32,6 +33,11 @@ const ROUTES: RouteConfig[] = [ component: Settings, exact: true, }, + { + path: '/encryption', + component: Encryption, + exact: true, + }, ]; const App = () => { diff --git a/client_v2/src/components/Encryption/Form.tsx b/client_v2/src/components/Encryption/Form.tsx new file mode 100644 index 00000000..41dfb574 --- /dev/null +++ b/client_v2/src/components/Encryption/Form.tsx @@ -0,0 +1,636 @@ +import React, { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import cn from 'clsx'; + +import { toNumber } from 'panel/helpers/form'; +import { DNS_OVER_QUIC_PORT, DNS_OVER_TLS_PORT, STANDARD_HTTPS_PORT, ENCRYPTION_SOURCE } from 'panel/helpers/constants'; +import { EncryptionData } from 'panel/initialState'; +import { + validateServerName, + validateIsSafePort, + validatePort, + validatePortQuic, + validatePortTLS, + validatePlainDns, +} from 'panel/helpers/validators'; +import { Checkbox } from 'panel/common/controls/Checkbox'; +import { Input } from 'panel/common/controls/Input'; +import { Radio } from 'panel/common/controls/Radio'; +import { Textarea } from 'panel/common/controls/Textarea'; +import { useDispatch } from 'react-redux'; +import { setTlsConfig, validateTlsConfig } from 'panel/actions/encryption'; +import { Button } from 'panel/common/ui/Button'; +import intl from 'panel/common/intl'; +import { SwitchGroup } from 'panel/common/ui/SettingsGroup'; +import theme from 'panel/lib/theme'; + +import { FaqTooltip } from 'panel/common/ui/FaqTooltip'; +import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog'; +import { KeyStatus, CertificateStatus, ValidationStatus } from './Status'; + +import s from './styles.module.pcss'; + +const certificateSourceOptions = [ + { + text: intl.getMessage('encryption_certificates_source_path'), + value: ENCRYPTION_SOURCE.PATH, + }, + { + text: intl.getMessage('encryption_certificates_source_content'), + value: ENCRYPTION_SOURCE.CONTENT, + }, +]; + +const keySourceOptions = [ + { + text: intl.getMessage('encryption_key_source_path'), + value: ENCRYPTION_SOURCE.PATH, + }, + { + text: intl.getMessage('encryption_key_source_content'), + value: ENCRYPTION_SOURCE.CONTENT, + }, +]; + +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; +}; + +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, debouncedConfigValidation }: Props) => { + const dispatch = useDispatch(); + const [openConfirmReset, setOpenConfirmReset] = useState(false); + const [openPlainDnsDisable, setOpenPlainDnsDisable] = useState(false); + const [stagedFormValues, setStagedFormValues] = useState(null); + + const { + not_after, + valid_chain, + valid_key, + valid_cert, + valid_pair, + key_type, + dns_names, + issuer, + subject, + warning_validation, + processingConfig, + processingValidate, + } = encryption; + + const { + control, + handleSubmit, + watch, + reset, + setValue, + setError, + getValues, + formState: { isSubmitting, isValid }, + } = useForm({ + 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 = isSubmitting || processingConfig || processingValidate; + + if (servePlainDns && !isEnabled) { + return !isValid || processing; + } + + return !isValid || processing || !valid_key || !valid_cert || !valid_pair; + }; + + const handleResetOpen = () => setOpenConfirmReset(true); + + const handleResetClose = () => setOpenConfirmReset(false); + + const handlePlainDnsDisableOpen = () => setOpenPlainDnsDisable(true); + + const handlePlainDnsDisableClose = () => { + setOpenPlainDnsDisable(false); + setStagedFormValues(null); + }; + + const handlePlainDnsDisableConfirm = () => { + if (stagedFormValues) { + onSubmit(stagedFormValues); + setStagedFormValues(null); + } + setOpenPlainDnsDisable(false); + }; + + const handleReset = () => { + reset(); + dispatch(setTlsConfig(defaultValues)); + dispatch(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 = intl.getMessage('form_error_equal'); + errors.port_https = intl.getMessage('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 }); + }); + return; + } + + if (data.serve_plain_dns === false) { + setStagedFormValues(data); + handlePlainDnsDisableOpen(); + return; + } + + onSubmit(data); + }; + + const renderCertificateStatus = () => { + if (warning_validation) { + const isWarning = valid_key && valid_cert && valid_pair; + + return ; + } + + if (!certificateChain && !certificatePath) { + return null; + } + + return ( + + ); + }; + + const isDisabled = isSavingDisabled(); + + return ( +
+ ( + +
+
+ ( + + {intl.getMessage('encryption_server')} + + +
+ {intl.getMessage('encryption_server_tooltip_1')} +
+
+ {intl.getMessage('encryption_server_tooltip_2')} +
+ + } + menuSize="large" + /> + + } + placeholder={intl.getMessage('encryption_server_enter')} + errorMessage={fieldState.error?.message} + disabled={!isEnabled} + onBlur={() => { + field.onBlur(); + handleBlur(); + }} + /> + )} + /> +
+
+ ( + + {intl.getMessage('encryption_https')} + + + + } + placeholder={intl.getMessage('encryption_https')} + errorMessage={fieldState.error?.message} + disabled={!isEnabled} + onChange={(e) => { + const { value } = e.target; + field.onChange(toNumber(value)); + }} + onBlur={() => { + field.onBlur(); + handleBlur(); + }} + /> + )} + /> +
+
+ ( + + {intl.getMessage('encryption_dot')} + + + + } + placeholder={intl.getMessage('encryption_dot')} + errorMessage={fieldState.error?.message} + disabled={!isEnabled} + onChange={(e) => { + const { value } = e.target; + field.onChange(toNumber(value)); + }} + onBlur={() => { + field.onBlur(); + handleBlur(); + }} + /> + )} + /> +
+
+ ( + + {intl.getMessage('encryption_doq')} + + + + } + placeholder={intl.getMessage('encryption_doq')} + errorMessage={fieldState.error?.message} + disabled={!isEnabled} + onChange={(e) => { + const { value } = e.target; + field.onChange(toNumber(value)); + }} + onBlur={() => { + field.onBlur(); + handleBlur(); + }} + /> + )} + /> +
+
+
+ )} + /> + + validatePlainDns(value, getValues()), + }} + render={({ field }) => ( + + )} + /> + + ( + + )} + /> + +

+ {intl.getMessage('encryption_certificates')} +

+ +

+ {intl.getMessage('encryption_certificates_desc', { + a: (text: string) => ( + + {text} + + ), + })} +

+ +
+ ( + + )} + /> + +
+ {certificateSource === ENCRYPTION_SOURCE.CONTENT ? ( + ( +