This commit is contained in:
Ildar Kamalov 2025-08-20 17:59:53 +03:00
parent a2da460426
commit a5fee78963
8 changed files with 263 additions and 195 deletions

View File

@ -791,7 +791,7 @@
"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_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",
@ -813,13 +813,21 @@
"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": "This will reset all encryption settings to default values",
"confirm": "Confirm"
"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

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

@ -1,5 +1,4 @@
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';
@ -96,9 +95,10 @@ const defaultValues = {
};
export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValidation }: Props) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
const [openConfirmReset, setOpenConfirmReset] = useState(false);
const [openPlainDnsDisable, setOpenPlainDnsDisable] = useState(false);
const [stagedFormValues, setStagedFormValues] = useState<EncryptionFormValues | null>(null);
const {
not_after,
@ -106,6 +106,7 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
valid_key,
valid_cert,
valid_pair,
key_type,
dns_names,
issuer,
subject,
@ -157,9 +158,24 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
return !isValid || processing || !valid_key || !valid_cert || !valid_pair;
};
const handleResetConfirmOpen = () => setOpenConfirmDialog(true);
const handleResetOpen = () => setOpenConfirmReset(true);
const handleResetConfirmClose = () => setOpenConfirmDialog(false);
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();
@ -187,13 +203,42 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
Object.entries(validationErrors).forEach(([field, message]) => {
setError(field as keyof EncryptionFormValues, { type: 'manual', message });
});
} else {
onSubmit(data);
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();
const isWarning = valid_key && valid_cert && valid_pair;
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
@ -206,8 +251,139 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
title={intl.getMessage('encryption_encrypted_dns')}
description={intl.getMessage('encryption_encrypted_dns_desc')}
checked={field.value}
onChange={field.onChange}
/>
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={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={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={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={handleBlur}
/>
)}
/>
</div>
</div>
</SwitchGroup>
)}
/>
@ -224,139 +400,11 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
description={intl.getMessage('encryption_plain_dns_desc')}
checked={field.value}
onChange={field.onChange}
disabled={!isEnabled}
/>
)}
/>
<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}
@ -408,10 +456,11 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
render={({ field, fieldState }) => (
<Textarea
{...field}
placeholder={t('encryption_certificates_input')}
placeholder={intl.getMessage('encryption_certificates_input')}
disabled={!isEnabled}
errorMessage={fieldState.error?.message}
onBlur={handleBlur}
size="large"
/>
)}
/>
@ -423,7 +472,7 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
<Input
{...field}
type="text"
placeholder={t('encryption_certificate_path')}
placeholder={intl.getMessage('encryption_certificate_path')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onBlur={handleBlur}
@ -434,16 +483,7 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
)}
</div>
{(certificateChain || certificatePath) && (
<CertificateStatus
validChain={valid_chain}
validCert={valid_cert}
subject={subject}
issuer={issuer}
notAfter={not_after}
dnsNames={dns_names}
/>
)}
{renderCertificateStatus()}
</div>
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
@ -481,7 +521,7 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
}}
onBlur={handleBlur}
className={s.useSavedKey}>
{t('use_saved_key')}
{intl.getMessage('use_saved_key')}
</Checkbox>
)}
/>
@ -494,10 +534,11 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
render={({ field, fieldState }) => (
<Textarea
{...field}
placeholder={t('encryption_key_input')}
placeholder={intl.getMessage('encryption_key_input')}
disabled={!isEnabled || privateKeySaved}
errorMessage={fieldState.error?.message}
onBlur={handleBlur}
size="large"
/>
)}
/>
@ -509,7 +550,7 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
<Input
{...field}
type="text"
placeholder={t('encryption_private_key_path')}
placeholder={intl.getMessage('encryption_private_key_path')}
errorMessage={fieldState.error?.message}
disabled={!isEnabled}
onBlur={handleBlur}
@ -520,14 +561,10 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
)}
</div>
{(privateKey || privateKeyPath) && <KeyStatus validKey={valid_key} />}
{(privateKey || privateKeyPath) && <KeyStatus validKey={valid_key} keyType={key_type} />}
</div>
<div className={theme.form.buttonGroup}>
{warning_validation && (
<ValidationStatus type={isWarning ? 'warning' : 'error'} message={warning_validation} />
)}
<Button
type="submit"
variant="primary"
@ -539,26 +576,38 @@ export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValid
<Button
type="button"
variant="secondary"
variant="secondary-danger"
size="small"
disabled={isSubmitting || processingConfig}
onClick={handleResetConfirmOpen}
onClick={handleResetOpen}
className={theme.form.button}>
<Trans>reset_settings</Trans>
{intl.getMessage('reset')}
</Button>
</div>
{openConfirmDialog && (
{openConfirmReset && (
<ConfirmDialog
onClose={handleResetConfirmClose}
onClose={handleResetClose}
onConfirm={handleReset}
buttonText={intl.getMessage('confirm')}
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

@ -2,13 +2,18 @@ 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 }: Props) => (
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

@ -1,25 +1,8 @@
.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);
}
@ -35,13 +18,9 @@
.statusList {
margin: 0;
padding: 8px 0;
padding: 0;
li {
display: block;
}
}
.statusText {
padding: 8px 0;
}

View File

@ -14,3 +14,9 @@
.tooltipText:not(:last-child) {
margin-bottom: 16px;
}
.group {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@ -3,7 +3,7 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 16px;
padding: 16px;
@media (min-width: 768px) {
flex-direction: row;