Pull request 2452: ADG-10295 add encryption settings page

Squashed commit of the following:

commit e841e3a0b4
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 26 15:49:58 2025 +0300

    fix todo

commit cf251e209b
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 26 15:48:31 2025 +0300

    fix lint issue

commit c47e8216ec
Merge: f754d7cd9 0e3692f9a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 26 15:38:36 2025 +0300

    Merge branch 'ADG-10291' into ADG-10295

commit f754d7cd97
Merge: d56ea7d8a febcc6814
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Aug 22 18:26:15 2025 +0300

    Merge branch 'ADG-10291' into ADG-10295

commit d56ea7d8a6
Author: Ildar Kamalov <ik@adguard.com>
Date:   Thu Aug 21 16:34:47 2025 +0300

    fix form validation

commit a5fee78963
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Aug 20 17:59:53 2025 +0300

    fix form

commit a2da460426
Merge: cff3f4588 ebfa5230a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Aug 15 17:30:28 2025 +0300

    Merge branch 'ADG-10291' into ADG-10295

commit cff3f45883
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Aug 13 19:28:46 2025 +0300

    ADG-10295 add encryption settings page

commit 3f6d759b7f
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 12 16:40:31 2025 +0300

    fix placeholder

commit 507631ab71
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 12 16:15:57 2025 +0300

    use batch

commit e495a5585c
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 12 16:13:13 2025 +0300

    review fix

commit 2ba671b408
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 12 10:45:00 2025 +0300

    fix

commit 6f696238a0
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 18:38:28 2025 +0300

    fix language change

commit 8206f3f885
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 14:54:58 2025 +0300

    fix custom radio

commit 0920a51c4a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 14:30:26 2025 +0300

    fix

commit c7ca1e8eaf
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 14:25:16 2025 +0300

    fix ids

commit 7ecd6426d9
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 13:56:29 2025 +0300

    fix constant import

commit 246f1277b8
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 13:46:11 2025 +0300

    fix version

commit 0224f567e6
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 13:34:29 2025 +0300

    fix version

commit 75c9caffbd
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 13:14:01 2025 +0300

    lint fix

commit d0cf6ad5ab
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 12:40:49 2025 +0300

    lint fix

commit 87eb68e519
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 12:06:35 2025 +0300

    use npm

commit 7df7d60ec9
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 11:23:00 2025 +0300

    rm unused

commit c285ea978a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 10:42:39 2025 +0300

    fix

commit d00f1e12b3
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 09:43:35 2025 +0300

    fix

... and 2 more commits
This commit is contained in:
Ildar Kamalov
2025-08-26 16:01:14 +03:00
parent 0e3692f9a0
commit be27c06481
36 changed files with 1246 additions and 122 deletions

View File

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

View File

@@ -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}}</0> 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</0>.",
"topline_expired_certificate": "Your SSL certificate is expired. Update <0>Encryption settings</0>.",
@@ -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 <a>letsencrypt.org</a> 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 <strong>%value%</strong>",
"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"
}

View File

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

View File

@@ -99,7 +99,9 @@
}
.inputLabel {
display: block;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
font-size: 14px;
line-height: 24px;

View File

@@ -3,7 +3,7 @@ import cn from 'clsx';
import s from './Input.module.pcss';
type Props = ComponentProps<'input'> & {
type Props = Omit<ComponentProps<'input'>, 'size'> & {
label?: ReactNode;
className?: string;
innerClassName?: string;

View File

@@ -33,5 +33,9 @@
}
.input:disabled + .handler .icon {
color: var(--disabled-gray-icons);
}
.input:disabled:checked + .handler .icon {
color: var(--disabled-main-button);
}

View File

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

View File

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

View File

@@ -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 (
<Dropdown
trigger="hover"
menu={
<div
className={cn(theme.dropdown.menu, s.menu, {
[s.menu_large]: menuSize === 'large',
})}>
{text}
</div>
}
className={s.dropdown}
position="bottomLeft"
noIcon>
<div className={s.trigger}>
<Icon icon="faq" className={s.icon} />
</div>
</Dropdown>
);
};

View File

@@ -0,0 +1 @@
export { FaqTooltip } from './FaqTooltip';

View File

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

View File

@@ -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<T> = { text: string; value: T };
type Props<T> = {
title: string;
description?: string;
disabled?: boolean;
value: T;
options: Option<T>[];
onChange: (value: T) => void;
className?: string;
children?: ReactNode;
name?: string;
};
export const RadioGroup = <T extends number | string | boolean>({
title,
description,
disabled,
value,
options,
onChange,
className,
children,
name,
}: Props<T>) => {
return (
<div className={cn(s.switch, className)}>
<div className={s.row}>
<div className={s.text}>
<div className={cn(s.title, theme.text.t2, theme.text.semibold)}>{title}</div>
{description && <div className={cn(s.desc, theme.text.t3)}>{description}</div>}
</div>
<div className={s.input} />
</div>
<div className={s.content}>
<Radio<T> disabled={disabled} value={value} options={options} handleChange={onChange} name={name} />
{children}
</div>
</div>
);
};

View File

@@ -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<HTMLInputElement>) => void;
disabled?: boolean;
children?: ReactNode;
};
export const SwitchGroup = ({ title, description, id, className, checked, onChange, disabled, children }: Props) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleRowClick = () => {
if (disabled || !inputRef.current) {
return;
}
inputRef.current?.click();
};
return (
<div className={cn(s.switch, className)}>
<div className={s.row} onClick={handleRowClick}>
<div className={s.text}>
<div className={cn(theme.text.t2, theme.text.semibold, s.title)}>{title}</div>
{description && <div className={cn(theme.text.t3, s.desc)}>{description}</div>}
</div>
<div className={s.input} onClick={(e) => e.stopPropagation()}>
<Switch id={id} checked={checked} onChange={onChange} disabled={disabled} ref={inputRef} />
</div>
</div>
{children && <div className={s.content}>{children}</div>}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { RadioGroup } from './RadioGroup';
export { SwitchGroup } from './SwitchGroup';

View File

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

View File

@@ -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 = () => {

View File

@@ -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<EncryptionFormValues | null>(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<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 = 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 <ValidationStatus type={isWarning ? 'warning' : 'error'} message={warning_validation} />;
}
if (!certificateChain && !certificatePath) {
return null;
}
return (
<CertificateStatus
validChain={valid_chain}
validCert={valid_cert}
subject={subject}
issuer={issuer}
notAfter={not_after}
dnsNames={dns_names}
/>
);
};
const isDisabled = isSavingDisabled();
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
name="enabled"
control={control}
render={({ field }) => (
<SwitchGroup
id="enabled"
title={intl.getMessage('encryption_encrypted_dns')}
description={intl.getMessage('encryption_encrypted_dns_desc')}
checked={field.value}
onChange={field.onChange}>
<div className={s.group}>
<div>
<Controller
name="server_name"
control={control}
rules={{ validate: validateServerName }}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
label={
<>
{intl.getMessage('encryption_server')}
<FaqTooltip
text={
<>
<div className={s.tooltipText}>
{intl.getMessage('encryption_server_tooltip_1')}
</div>
<div className={s.tooltipText}>
{intl.getMessage('encryption_server_tooltip_2')}
</div>
</>
}
menuSize="large"
/>
</>
}
placeholder={intl.getMessage('encryption_server_enter')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onBlur={() => {
field.onBlur();
handleBlur();
}}
/>
)}
/>
</div>
<div>
<Controller
name="port_https"
control={control}
rules={{ validate: { validatePort, validateIsSafePort } }}
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
label={
<>
{intl.getMessage('encryption_https')}
<FaqTooltip
text={intl.getMessage('encryption_https_tooltip')}
menuSize="large"
/>
</>
}
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();
}}
/>
)}
/>
</div>
<div>
<Controller
name="port_dns_over_tls"
control={control}
rules={{ validate: validatePortTLS }}
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
label={
<>
{intl.getMessage('encryption_dot')}
<FaqTooltip
text={intl.getMessage('encryption_dot_tooltip')}
menuSize="large"
/>
</>
}
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();
}}
/>
)}
/>
</div>
<div>
<Controller
name="port_dns_over_quic"
control={control}
rules={{ validate: validatePortQuic }}
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
label={
<>
{intl.getMessage('encryption_doq')}
<FaqTooltip
text={intl.getMessage('encryption_doq_tooltip')}
menuSize="large"
/>
</>
}
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();
}}
/>
)}
/>
</div>
</div>
</SwitchGroup>
)}
/>
<Controller
name="serve_plain_dns"
control={control}
rules={{
validate: (value) => validatePlainDns(value, getValues()),
}}
render={({ field }) => (
<SwitchGroup
id="serve_plain_dns"
title={intl.getMessage('encryption_plain_dns')}
description={intl.getMessage('encryption_plain_dns_desc')}
checked={field.value}
onChange={field.onChange}
disabled={!isEnabled}
/>
)}
/>
<Controller
name="force_https"
control={control}
render={({ field }) => (
<SwitchGroup
id="force_https"
title={intl.getMessage('encryption_force_redirect')}
checked={field.value}
onChange={field.onChange}
disabled={!isEnabled}
/>
)}
/>
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
{intl.getMessage('encryption_certificates')}
</h2>
<p className={cn(s.description, theme.text.t2)}>
{intl.getMessage('encryption_certificates_desc', {
a: (text: string) => (
<a href="https://letsencrypt.org/" target="_blank" rel="noreferrer" className={theme.link.link}>
{text}
</a>
),
})}
</p>
<div className={theme.form.group}>
<Controller
name="certificate_source"
control={control}
render={({ field }) => (
<Radio
value={field.value}
handleChange={field.onChange}
name={field.name}
options={certificateSourceOptions}
disabled={!isEnabled}
/>
)}
/>
<div className={theme.form.input}>
{certificateSource === ENCRYPTION_SOURCE.CONTENT ? (
<Controller
name="certificate_chain"
control={control}
render={({ field, fieldState }) => (
<Textarea
{...field}
placeholder={intl.getMessage('encryption_certificates_input')}
disabled={!isEnabled}
errorMessage={fieldState.error?.message}
onBlur={() => {
field.onBlur();
handleBlur();
}}
size="large"
/>
)}
/>
) : (
<Controller
name="certificate_path"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
placeholder={intl.getMessage('encryption_certificate_path')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onBlur={() => {
field.onBlur();
handleBlur();
}}
size="medium"
/>
)}
/>
)}
</div>
{renderCertificateStatus()}
</div>
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
{intl.getMessage('encryption_key')}
</h2>
<div className={theme.form.group}>
<Controller
name="key_source"
control={control}
render={({ field }) => (
<Radio
value={field.value}
handleChange={field.onChange}
name={field.name}
options={keySourceOptions}
disabled={!isEnabled}
/>
)}
/>
<Controller
name="private_key_saved"
control={control}
render={({ field: { value, onChange, name } }) => (
<Checkbox
name={name}
disabled={!isEnabled || privateKeySource !== ENCRYPTION_SOURCE.CONTENT}
checked={value}
onChange={({ target: { checked } }) => {
if (checked) {
setValue('private_key', '');
}
onChange(checked);
}}
onBlur={handleBlur}
className={s.useSavedKey}>
{intl.getMessage('use_saved_key')}
</Checkbox>
)}
/>
<div className={theme.form.input}>
{privateKeySource === ENCRYPTION_SOURCE.CONTENT ? (
<Controller
name="private_key"
control={control}
render={({ field, fieldState }) => (
<Textarea
{...field}
placeholder={intl.getMessage('encryption_key_input')}
disabled={!isEnabled || privateKeySaved}
errorMessage={fieldState.error?.message}
onBlur={() => {
field.onBlur();
handleBlur();
}}
size="large"
/>
)}
/>
) : (
<Controller
name="private_key_path"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
placeholder={intl.getMessage('encryption_private_key_path')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onBlur={() => {
field.onBlur();
handleBlur();
}}
size="medium"
/>
)}
/>
)}
</div>
{(privateKey || privateKeyPath) && <KeyStatus validKey={valid_key} keyType={key_type} />}
</div>
<div className={theme.form.buttonGroup}>
<Button
type="submit"
variant="primary"
size="small"
disabled={isDisabled}
className={theme.form.button}>
{intl.getMessage('save')}
</Button>
<Button
type="button"
variant="secondary-danger"
size="small"
disabled={isSubmitting || processingConfig}
onClick={handleResetOpen}
className={theme.form.button}>
{intl.getMessage('reset')}
</Button>
</div>
{openConfirmReset && (
<ConfirmDialog
onClose={handleResetClose}
onConfirm={handleReset}
buttonText={intl.getMessage('reset')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('encryption_confirm_clear')}
text={intl.getMessage('encryption_confirm_clear_desc')}
buttonVariant="danger"
/>
)}
{openPlainDnsDisable && (
<ConfirmDialog
onClose={handlePlainDnsDisableClose}
onConfirm={handlePlainDnsDisableConfirm}
buttonText={intl.getMessage('disable')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('encryption_disable_plain_dns')}
text={intl.getMessage('encryption_disable_plain_dns_desc')}
buttonVariant="danger"
/>
)}
</form>
);
};

View File

@@ -0,0 +1,34 @@
import React from 'react';
import format from 'date-fns/format';
import { EMPTY_DATE } from 'panel/helpers/constants';
import intl from 'panel/common/intl';
import { StatusBlock } from './StatusBlock';
import s from './styles.module.pcss';
type Props = {
validChain: boolean;
validCert: boolean;
subject?: string;
issuer?: string;
notAfter?: string;
dnsNames?: string[];
};
export const CertificateStatus = ({ validChain, validCert, subject, issuer, notAfter, dnsNames }: Props) => (
<StatusBlock
variant={validChain ? 'success' : 'error'}
title={validChain ? intl.getMessage('encryption_chain_valid') : intl.getMessage('encryption_chain_invalid')}
>
{validCert && (subject || issuer || notAfter || dnsNames) && (
<ul className={s.statusList}>
{subject && <li>{intl.getMessage('encryption_subject', { value: subject })}</li>}
{issuer && <li>{intl.getMessage('encryption_issuer', { value: issuer })}</li>}
{notAfter && notAfter !== EMPTY_DATE && (
<li>{intl.getMessage('encryption_expire', { value: format(notAfter, 'YYYY-MM-DD HH:mm:ss') })}</li>
)}
{dnsNames && <li>{intl.getMessage('encryption_hostnames', { value: dnsNames.join(', ') })}</li>}
</ul>
)}
</StatusBlock>
);

View File

@@ -0,0 +1,19 @@
import React from 'react';
import intl from 'panel/common/intl';
import { StatusBlock } from './StatusBlock';
import s from './styles.module.pcss';
type Props = {
validKey: boolean;
keyType?: string;
};
export const KeyStatus = ({ validKey, keyType }: Props) => (
<StatusBlock
variant={validKey ? 'success' : 'error'}
title={validKey ? intl.getMessage('encryption_key_valid') : intl.getMessage('encryption_key_invalid')}
>
{keyType && <div className={s.statusText}>{intl.getMessage('encryption_key_type', { value: keyType })}</div>}
</StatusBlock>
);

View File

@@ -0,0 +1,28 @@
import React, { ReactNode } from 'react';
import cn from 'clsx';
import theme from 'panel/lib/theme';
import s from './styles.module.pcss';
export type StatusVariant = 'success' | 'error' | 'warning';
type Props = {
variant: StatusVariant;
title: ReactNode;
children?: ReactNode;
};
export const StatusBlock = ({ variant, title, children }: Props) => (
<div className={cn(s.status, theme.text.t3)}>
<div
className={cn(s.statusTitle, {
[s.statusTitle_success]: variant === 'success',
[s.statusTitle_error]: variant === 'error',
[s.statusTitle_warning]: variant === 'warning',
})}>
{title}
</div>
{children}
</div>
);

View File

@@ -0,0 +1,17 @@
import React from 'react';
import intl from 'panel/common/intl';
import { StatusBlock } from './StatusBlock';
import s from './styles.module.pcss';
type Props = {
type: 'warning' | 'error';
message: string;
};
export const ValidationStatus = ({ type, message }: Props) => (
<StatusBlock variant={type} title={intl.getMessage('encryption_certificate_has_issues')}>
<div className={s.statusText}>{message}</div>
</StatusBlock>
);

View File

@@ -0,0 +1,3 @@
export { CertificateStatus } from './CertificateStatus';
export { KeyStatus } from './KeyStatus';
export { ValidationStatus } from './ValidationStatus';

View File

@@ -0,0 +1,26 @@
.status {
color: var(--default-main-text);
}
.statusTitle {
&_success {
color: var(--default-link);
}
&_warning {
color: var(--default-attention-link);
}
&_error {
color: var(--default-error-link);
}
}
.statusList {
margin: 0;
padding: 0;
li {
display: block;
}
}

View File

@@ -0,0 +1,115 @@
import React, { useCallback, useMemo } from 'react';
import { debounce } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import cn from 'clsx';
import { DEBOUNCE_TIMEOUT, ENCRYPTION_SOURCE } from 'panel/helpers/constants';
import { RootState } from 'panel/initialState';
import { PageLoader } from 'panel/common/ui/Loader';
import { setTlsConfig, validateTlsConfig } from 'panel/actions/encryption';
import theme from 'panel/lib/theme';
import intl from 'panel/common/intl';
import { EncryptionFormValues, Form } from './Form';
import s from './styles.module.pcss';
export const Encryption = () => {
const dispatch = useDispatch();
const encryption = useSelector((state: RootState) => state.encryption);
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 {
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]);
const getSubmitValues = useCallback((values: any) => {
const { certificate_source, key_source, private_key_saved, ...config } = values;
if (certificate_source === ENCRYPTION_SOURCE.PATH) {
config.certificate_chain = '';
} else {
config.certificate_path = '';
}
if (key_source === ENCRYPTION_SOURCE.PATH) {
config.private_key = '';
} else {
config.private_key_path = '';
if (private_key_saved) {
config.private_key = '';
config.private_key_saved = private_key_saved;
}
}
return config;
}, []);
const handleFormSubmit = useCallback(
(values: any) => {
const submitValues = getSubmitValues(values);
dispatch(setTlsConfig(submitValues));
},
[getSubmitValues, setTlsConfig],
);
const validateConfig = useCallback((values) => {
const submitValues = getSubmitValues(values);
if (submitValues.enabled) {
dispatch(validateTlsConfig(submitValues));
}
}, []);
const debouncedConfigValidation = useMemo(() => debounce(validateConfig, DEBOUNCE_TIMEOUT), [validateConfig]);
return (
<div className={theme.layout.container}>
<h1 className={cn(theme.layout.title, theme.title.h4, theme.title.h3_tablet, s.title)}>
{intl.getMessage('encryption_title')}
</h1>
{encryption.processing ? (
<PageLoader />
) : (
<Form
initialValues={initialValues}
onSubmit={handleFormSubmit}
debouncedConfigValidation={debouncedConfigValidation}
encryption={encryption}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,22 @@
.title {
padding-bottom: 16px;
}
.description {
margin: 0;
padding: 0 16px 16px;
}
.useSavedKey {
padding: 8px 0 8px 32px;
}
.tooltipText:not(:last-child) {
margin-bottom: 16px;
}
.group {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@@ -1,14 +1,13 @@
import React, { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useDispatch } from 'react-redux';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { RoutePath } from 'panel/components/Routes/Paths';
import { Link } from 'panel/common/ui/Link';
import { setFiltersConfig } from 'panel/actions/filtering';
import { useDispatch } from 'react-redux';
import { SwitchGroup } from './SettingsGroup';
import { SwitchGroup } from 'panel/common/ui/SettingsGroup';
export type FormValues = {
enabled: boolean;

View File

@@ -5,12 +5,11 @@ import cn from 'clsx';
import { Textarea } from 'panel/common/controls/Textarea';
import intl from 'panel/common/intl';
import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers';
import { Dropdown } from 'panel/common/ui/Dropdown';
import theme from 'panel/lib/theme';
import { Icon } from 'panel/common/ui/Icon';
import { SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { FaqTooltip } from 'panel/common/ui/FaqTooltip';
import s from './styles.module.pcss';
import { SwitchGroup } from '../SettingsGroup';
type Props = {
control: Control<any>;
@@ -38,30 +37,25 @@ export const IgnoredDomains = ({ control, processing, ignoreEnabled, setValue, s
{...field}
id={textareaId}
label={
<div className={s.label}>
<>
{intl.getMessage('settings_domain_names')}
<Dropdown
trigger="hover"
menu={
<div className={cn(theme.dropdown.menu, s.dropdownMenu)}>
<FaqTooltip
text={
<>
<div className={s.dropdownTitle}>
{intl.getMessage('settings_tooltip_domain_names')}
</div>
<div className={s.dropdownText}>
<div>
<strong>{intl.getMessage('settings_tooltip_examples')}</strong>
<div>example.com</div>
<div>*.example.com</div>
<div>||example.com^</div>
</div>
</div>
</>
}
className={s.dropdown}
position="bottomLeft"
noIcon>
<div className={s.dropdownTrigger}>
<Icon icon="faq" className={s.icon} />
</div>
</Dropdown>
/>
<a
href="https://link.adtidy.org/forward.html?action=dns_kb_filtering_syntax&from=ui&app=home"
target="_blank"
@@ -69,7 +63,7 @@ export const IgnoredDomains = ({ control, processing, ignoreEnabled, setValue, s
className={cn(s.link, theme.link.link, theme.link.noDecoration)}>
{intl.getMessage('settings_rule_syntax')}
</a>
</div>
</>
}
placeholder={`example.com\n*.example.com\n||example.com^`}
size="large"

View File

@@ -9,20 +9,6 @@
gap: 8px;
}
.dropdownTrigger {
cursor: pointer;
color: var(--default-gray-icons);
}
.dropdownMenu {
padding: 16px;
max-width: 240px;
@media (min-width: 768px) {
max-width: 280px;
}
}
.dropdownTitle {
margin-bottom: 16px;
}

View File

@@ -6,8 +6,7 @@ import { Button } from 'panel/common/ui/Button';
import theme from 'panel/lib/theme';
import { QUERY_LOG_INTERVALS_DAYS, RETENTION_CUSTOM } from 'panel/helpers/constants';
import { RadioGroup } from '../SettingsGroup/RadioGroup';
import { SwitchGroup } from '../SettingsGroup';
import { RadioGroup, SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { IgnoredDomains } from '../IgnoredDomains';
import { getIntervalTitle, getDefaultInterval } from '../helpers';
import { RetentionCustomInput } from '../RetentionCustomInput';

View File

@@ -11,12 +11,12 @@ import { initSettings, toggleSetting } from 'panel/actions';
import { getStatsConfig } from 'panel/actions/stats';
import { getLogsConfig } from 'panel/actions/queryLogs';
import { getFilteringStatus } from 'panel/actions/filtering';
import { SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { StatsConfig } from './StatsConfig/StatsConfig';
import { LogsConfig } from './LogsConfig';
import { FiltersConfig } from './FiltersConfig';
import { getSafeSearchProviderTitle } from './helpers';
import { SwitchGroup } from './SettingsGroup';
const SETTINGS = {
safebrowsing: {
@@ -48,9 +48,8 @@ export const Settings = () => {
});
}, []);
const handleSettingToggle =
(key: keyof typeof SETTINGS) => (e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(toggleSetting(key, !e.target.checked));
const handleSettingToggle = (key: keyof typeof SETTINGS) => (e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(toggleSetting(key, !e.target.checked));
const renderSettings = (settingsList?: SettingsData['settingsList']) =>
settingsList
@@ -82,20 +81,18 @@ export const Settings = () => {
type SafeSearchConfigShape = Record<string, boolean> & { enabled: boolean };
const onSafeSearchEnabledChange =
(e: React.ChangeEvent<HTMLInputElement>) => {
const payload = { ...safesearch, enabled: e.target.checked } as SafeSearchConfigShape;
dispatch(toggleSetting('safesearch', payload));
};
const onSafeSearchEnabledChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const payload = { ...safesearch, enabled: e.target.checked } as SafeSearchConfigShape;
dispatch(toggleSetting('safesearch', payload));
};
const onProviderChange =
(searchKey: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
const payload = {
...safesearch,
[searchKey]: e.target.checked,
} as SafeSearchConfigShape;
dispatch(toggleSetting('safesearch', payload));
};
const onProviderChange = (searchKey: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
const payload = {
...safesearch,
[searchKey]: e.target.checked,
} as SafeSearchConfigShape;
dispatch(toggleSetting('safesearch', payload));
};
return (
<SwitchGroup

View File

@@ -4,10 +4,10 @@ import { Controller, useForm } from 'react-hook-form';
import { Button } from 'panel/common/ui/Button';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { getIntervalTitle, getDefaultInterval } from '../helpers';
import { RadioGroup, SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { getIntervalTitle, getDefaultInterval } from '../helpers';
import { STATS_INTERVALS_DAYS, RETENTION_CUSTOM } from '../../../helpers/constants';
import { RadioGroup, SwitchGroup } from '../SettingsGroup';
import { IgnoredDomains } from '../IgnoredDomains';
import { RetentionCustomInput } from '../RetentionCustomInput';

View File

@@ -1,5 +1,4 @@
import i18next from 'i18next';
import intl from 'panel/common/intl';
import {
MAX_PORT,
R_CIDR,
@@ -34,7 +33,7 @@ export const validateRequiredValue = (value: any) => {
if (formattedValue || formattedValue === 0 || (formattedValue && formattedValue.length !== 0)) {
return undefined;
}
return i18next.t('form_error_required');
return intl.getMessage('form_error_required');
};
/**
@@ -50,7 +49,7 @@ export const validateIpv4RangeEnd = (_: any, allValues: any) => {
const { range_end, range_start } = allValues.v4;
if (ip4ToInt(range_end) <= ip4ToInt(range_start)) {
return i18next.t('greater_range_start_error');
return intl.getMessage('greater_range_start_error');
}
return undefined;
@@ -62,7 +61,7 @@ export const validateIpv4RangeEnd = (_: any, allValues: any) => {
*/
export const validateIpv4 = (value: any) => {
if (value && !R_IPV4.test(value)) {
return i18next.t('form_error_ip4_format');
return intl.getMessage('form_error_ip4_format');
}
return undefined;
};
@@ -91,7 +90,7 @@ export const validateNotInRange = (value: any, allValues: any) => {
const isBelowMax = range_end && ip4ToInt(value) <= ip4ToInt(range_end);
if (isAboveMin && isBelowMax) {
return i18next.t('out_of_range_error', {
return intl.getMessage('out_of_range_error', {
start: range_start,
end: range_end,
});
@@ -107,16 +106,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 i18next.t('gateway_or_subnet_invalid');
return intl.getMessage('gateway_or_subnet_invalid');
}
const { subnet_mask, gateway_ip } = allValues.v4;
if (validateIpv4(gateway_ip)) {
return i18next.t('gateway_or_subnet_invalid');
return intl.getMessage('gateway_or_subnet_invalid');
}
return parseSubnetMask(subnet_mask) ? undefined : i18next.t('gateway_or_subnet_invalid');
return parseSubnetMask(subnet_mask) ? undefined : intl.getMessage('gateway_or_subnet_invalid');
};
/**
@@ -138,7 +137,7 @@ export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => {
const subnetPrefix = parseSubnetMask(subnet_mask);
if (!isIpInCidr(value, `${gateway_ip}/${subnetPrefix}`)) {
return i18next.t('subnet_error');
return intl.getMessage('subnet_error');
}
return undefined;
@@ -164,7 +163,7 @@ export const validateClientId = (value: string) => {
R_CLIENT_ID.test(formattedValue)
)
) {
return i18next.t('form_error_client_id_format');
return intl.getMessage('form_error_client_id_format');
}
return undefined;
};
@@ -179,7 +178,7 @@ export const validateConfigClientId = (value: any) => {
}
const formattedValue = value.trim();
if (formattedValue && !R_CLIENT_ID.test(formattedValue)) {
return i18next.t('form_error_client_id_format');
return intl.getMessage('form_error_client_id_format');
}
return undefined;
};
@@ -194,7 +193,7 @@ export const validateServerName = (value: any) => {
}
const formattedValue = value ? value.trim() : value;
if (formattedValue && !R_DOMAIN.test(formattedValue)) {
return i18next.t('form_error_server_name');
return intl.getMessage('form_error_server_name');
}
return undefined;
};
@@ -205,7 +204,7 @@ export const validateServerName = (value: any) => {
*/
export const validateIpv6 = (value: any) => {
if (value && !R_IPV6.test(value)) {
return i18next.t('form_error_ip6_format');
return intl.getMessage('form_error_ip6_format');
}
return undefined;
};
@@ -216,7 +215,7 @@ export const validateIpv6 = (value: any) => {
*/
export const validateIp = (value: any) => {
if (value && !R_IPV4.test(value) && !R_IPV6.test(value)) {
return i18next.t('form_error_ip_format');
return intl.getMessage('form_error_ip_format');
}
return undefined;
};
@@ -227,7 +226,7 @@ export const validateIp = (value: any) => {
*/
export const validateMac = (value: any) => {
if (value && !R_MAC.test(value)) {
return i18next.t('form_error_mac_format');
return intl.getMessage('form_error_mac_format');
}
return undefined;
};
@@ -238,7 +237,7 @@ export const validateMac = (value: any) => {
*/
export const validatePort = (value: any) => {
if ((value || value === 0) && (value < STANDARD_WEB_PORT || value > MAX_PORT)) {
return i18next.t('form_error_port_range');
return intl.getMessage('form_error_port_range');
}
return undefined;
};
@@ -249,7 +248,7 @@ export const validatePort = (value: any) => {
*/
export const validateInstallPort = (value: any) => {
if (value < 1 || value > MAX_PORT) {
return i18next.t('form_error_port');
return intl.getMessage('form_error_port');
}
return undefined;
};
@@ -263,7 +262,7 @@ export const validatePortTLS = (value: any) => {
return undefined;
}
if (value && (value < STANDARD_WEB_PORT || value > MAX_PORT)) {
return i18next.t('form_error_port_range');
return intl.getMessage('form_error_port_range');
}
return undefined;
};
@@ -280,7 +279,7 @@ export const validatePortQuic = validatePortTLS;
*/
export const validateIsSafePort = (value: any) => {
if (UNSAFE_PORTS.includes(value)) {
return i18next.t('form_error_port_unsafe');
return intl.getMessage('form_error_port_unsafe');
}
return undefined;
};
@@ -291,7 +290,7 @@ export const validateIsSafePort = (value: any) => {
*/
export const validateDomain = (value: any) => {
if (value && !R_HOST.test(value)) {
return i18next.t('form_error_domain_format');
return intl.getMessage('form_error_domain_format');
}
return undefined;
};
@@ -302,7 +301,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 i18next.t('form_error_answer_format');
return intl.getMessage('form_error_answer_format');
}
return undefined;
};
@@ -313,7 +312,7 @@ export const validateAnswer = (value: any) => {
*/
export const validatePath = (value: any) => {
if (value && !isValidAbsolutePath(value) && !R_URL_REQUIRES_PROTOCOL.test(value)) {
return i18next.t('form_error_url_or_path_format');
return intl.getMessage('form_error_url_or_path_format');
}
return undefined;
};
@@ -324,7 +323,7 @@ export const validatePath = (value: any) => {
*/
export const validateIpv4InCidr = (valueIp: any, allValues: any) => {
if (!isIpInCidr(valueIp, allValues.cidr)) {
return i18next.t('form_error_subnet', { ip: valueIp, cidr: allValues.cidr });
return intl.getMessage('form_error_subnet', { ip: valueIp, cidr: allValues.cidr });
}
return undefined;
};
@@ -349,7 +348,7 @@ export const validatePasswordLength = (value: any) => {
const length = utf8StringLength(value);
if (length < MIN_PASSWORD_LENGTH || length > MAX_PASSWORD_LENGTH) {
// TODO: Make the i18n clearer with regards to bytes vs. characters.
return i18next.t('form_error_password_length', {
return intl.getMessage('form_error_password_length', {
min: MIN_PASSWORD_LENGTH,
max: MAX_PASSWORD_LENGTH,
});
@@ -365,7 +364,7 @@ export const validatePasswordLength = (value: any) => {
*/
export const validateIpGateway = (value: any, allValues: any) => {
if (value === allValues.gatewayIp) {
return i18next.t('form_error_gateway_ip');
return intl.getMessage('form_error_gateway_ip');
}
return undefined;
};
@@ -376,7 +375,7 @@ export const validateIpGateway = (value: any, allValues: any) => {
*/
export const validateIPv4Subnet = (value: any) => {
if (!R_IPV4_SUBNET.test(value)) {
return i18next.t('rate_limit_subnet_len_ipv4_error');
return intl.getMessage('rate_limit_subnet_len_ipv4_error');
}
return undefined;
};
@@ -387,7 +386,7 @@ export const validateIPv4Subnet = (value: any) => {
*/
export const validateIPv6Subnet = (value: any) => {
if (!R_IPV6_SUBNET.test(value)) {
return i18next.t('rate_limit_subnet_len_ipv6_error');
return intl.getMessage('rate_limit_subnet_len_ipv6_error');
}
return undefined;
};
@@ -401,7 +400,7 @@ export const validatePlainDns = (value: any, allValues: any) => {
const { enabled } = allValues;
if (!enabled && !value) {
return i18next.t('encryption_plain_dns_error');
return intl.getMessage('encryption_plain_dns_error');
}
return undefined;

View File

@@ -1,8 +1,9 @@
.buttonGroup {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 16px;
padding: 16px;
@media (min-width: 768px) {
flex-direction: row;
@@ -28,3 +29,8 @@
align-items: center;
padding: 8px 0;
}
.group {
position: relative;
padding: 0 16px;
}

View File

@@ -6,7 +6,7 @@ set -e -f -u -x
# for this database is https://github.com/AdguardTeam/companiesdb.
#
trackers_url='https://raw.githubusercontent.com/AdguardTeam/companiesdb/main/dist/trackers.json'
# TODO: Update output path to './client_v2/src/helpers/trackers/trackers.json' for new frontend migration
# TODO(ik): Update output path to './client_v2/src/helpers/trackers/trackers.json' for new frontend migration
output='./client/src/helpers/trackers/trackers.json'
readonly trackers_url output

View File

@@ -25,13 +25,12 @@ import (
"github.com/AdguardTeam/golibs/osutil"
)
// TODO(ik): Update localesDir and srcDir to "./client_v2/src/" for new frontend migration
const (
twoskyConfFile = "./.twosky.json"
// TODO: Update localesDir to "./client_v2/src/__locales" for new frontend migration
localesDir = "./client/src/__locales"
defaultBaseFile = "en.json"
defaultProjectID = "home"
// TODO: Update srcDir to "./client_v2/src" for new frontend migration
srcDir = "./client/src"
twoskyURI = "https://twosky.int.agrd.dev/api/v1"
@@ -79,8 +78,7 @@ func main() {
usage("")
}
// TODO: Support multiple projects in .twosky.json - add PROJECT_ID env var to select between 'home' and 'home_v2'
// TODO: Modify readTwoskyConfig() to return all configs and add selectProject() function
// TODO(ik): Support multiple projects in .twosky.json - add PROJECT_ID env var to select between 'home' and 'home_v2'
conf := errors.Must(readTwoskyConfig())
var cli *twoskyClient
@@ -110,7 +108,7 @@ func main() {
// usage prints usage. If addStr is not empty print addStr and exit with code
// 1, otherwise exit with code 0.
func usage(addStr string) {
// TODO: Add PROJECT_ID environment variable documentation when multi-project support is implemented
// TODO(ik): Add PROJECT_ID environment variable documentation when multi-project support is implemented
const usageStr = `Usage: go run main.go <command> [<args>]
Commands:
help
@@ -166,7 +164,7 @@ func readTwoskyConfig() (t *twoskyConfig, err error) {
return nil, fmt.Errorf("%q is empty", twoskyConfFile)
}
// TODO: Currently only uses first project - modify to support project selection
// TODO(ik): Currently only uses first project - modify to support project selection
conf := tsc[0]
for _, lang := range conf.Languages {

View File

@@ -95,7 +95,7 @@ func main() {
errors.Check(enc.Encode(aghFlt))
// TODO: Update output path to "client_v2/src/helpers/filters/filters.ts" for new frontend migration
// TODO(ik): Update output path to "client_v2/src/helpers/filters/filters.ts" for new frontend migration
err = maybe.WriteFile("client/src/helpers/filters/filters.ts", buf.Bytes(), 0o644)
errors.Check(err)
}