ADG-10295 add encryption settings page

This commit is contained in:
Ildar Kamalov 2025-08-13 19:28:46 +03:00
parent 3f6d759b7f
commit cff3f45883
29 changed files with 980 additions and 82 deletions

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,38 @@
"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 can use 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_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": "This will reset all encryption settings to default values",
"confirm": "Confirm"
}

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

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

@ -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 Toasts from '../Toasts';
import i18n from '../../i18n';
import { THEMES } from '../../helpers/constants';
@ -31,6 +32,11 @@ const ROUTES: RouteConfig[] = [
component: Settings,
exact: true,
},
{
path: '/encryption',
component: Encryption,
exact: true,
},
];
const App = () => {

View File

@ -0,0 +1,564 @@
import React, { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Controller, useForm } from 'react-hook-form';
import i18next from 'i18next';
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: i18next.t('encryption_certificates_source_path'),
value: ENCRYPTION_SOURCE.PATH,
},
{
text: i18next.t('encryption_certificates_source_content'),
value: ENCRYPTION_SOURCE.CONTENT,
},
];
const keySourceOptions = [
{
text: i18next.t('encryption_key_source_path'),
value: ENCRYPTION_SOURCE.PATH,
},
{
text: i18next.t('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 { t } = useTranslation();
const dispatch = useDispatch();
const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
const {
not_after,
valid_chain,
valid_key,
valid_cert,
valid_pair,
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 handleResetConfirmOpen = () => setOpenConfirmDialog(true);
const handleResetConfirmClose = () => setOpenConfirmDialog(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 = 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(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}
/>
)}
/>
<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}
/>
)}
/>
<div className={theme.form.group}>
<div className={theme.form.input}>
<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={t('encryption_server_enter')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onBlur={handleBlur}
/>
)}
/>
</div>
<div className={theme.form.input}>
<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={t('encryption_https')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
onBlur={handleBlur}
/>
)}
/>
</div>
<div className={theme.form.input}>
<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={t('encryption_dot')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
onBlur={handleBlur}
/>
)}
/>
</div>
<div className={theme.form.input}>
<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={t('encryption_doq')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
onBlur={handleBlur}
/>
)}
/>
</div>
</div>
<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={t('encryption_certificates_input')}
disabled={!isEnabled}
errorMessage={fieldState.error?.message}
onBlur={handleBlur}
/>
)}
/>
) : (
<Controller
name="certificate_path"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
placeholder={t('encryption_certificate_path')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onBlur={handleBlur}
size="medium"
/>
)}
/>
)}
</div>
{(certificateChain || certificatePath) && (
<CertificateStatus
validChain={valid_chain}
validCert={valid_cert}
subject={subject}
issuer={issuer}
notAfter={not_after}
dnsNames={dns_names}
/>
)}
</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}>
{t('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={t('encryption_key_input')}
disabled={!isEnabled || privateKeySaved}
errorMessage={fieldState.error?.message}
onBlur={handleBlur}
/>
)}
/>
) : (
<Controller
name="private_key_path"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
placeholder={t('encryption_private_key_path')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onBlur={handleBlur}
size="medium"
/>
)}
/>
)}
</div>
{(privateKey || privateKeyPath) && <KeyStatus validKey={valid_key} />}
</div>
<div className={theme.form.buttonGroup}>
{warning_validation && (
<ValidationStatus type={isWarning ? 'warning' : 'error'} message={warning_validation} />
)}
<Button
type="submit"
variant="primary"
size="small"
disabled={isDisabled}
className={theme.form.button}>
{intl.getMessage('save')}
</Button>
<Button
type="button"
variant="secondary"
size="small"
disabled={isSubmitting || processingConfig}
onClick={handleResetConfirmOpen}
className={theme.form.button}>
<Trans>reset_settings</Trans>
</Button>
</div>
{openConfirmDialog && (
<ConfirmDialog
onClose={handleResetConfirmClose}
onConfirm={handleReset}
buttonText={intl.getMessage('confirm')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('encryption_confirm_clear')}
text={intl.getMessage('encryption_confirm_clear_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,14 @@
import React from 'react';
import intl from 'panel/common/intl';
import { StatusBlock } from './StatusBlock';
type Props = {
validKey: boolean;
};
export const KeyStatus = ({ validKey }: Props) => (
<StatusBlock
variant={validKey ? 'success' : 'error'}
title={validKey ? intl.getMessage('encryption_key_valid') : intl.getMessage('encryption_key_invalid')}
/>
);

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,47 @@
.status {
display: flex;
flex-direction: column;
padding: 8px 16px;
color: var(--default-main-text);
border-radius: 8px;
box-shadow: var(--main-box-shadow);
background-color: var(--fills-backgrounds-page-background-additional);
cursor: default;
@media (min-width: 1024px) {
position: absolute;
bottom: 8px;
left: calc(100% + 56px);
width: 340px;
}
}
.statusTitle {
padding: 8px 0;
font-weight: var(--weight-semi-bold);
&_success {
color: var(--default-link);
}
&_warning {
color: var(--default-attention-link);
}
&_error {
color: var(--default-error-link);
}
}
.statusList {
margin: 0;
padding: 8px 0;
li {
display: block;
}
}
.statusText {
padding: 8px 0;
}

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,16 @@
.title {
padding-bottom: 16px;
}
.description {
margin: 0;
padding: 0 16px 16px;
}
.useSavedKey {
padding: 8px 0 8px 32px;
}
.tooltipText:not(:last-child) {
margin-bottom: 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,4 +1,5 @@
.buttonGroup {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
@ -28,3 +29,8 @@
align-items: center;
padding: 8px 0;
}
.group {
position: relative;
padding: 0 16px;
}