mirror of
https://git.vectorsigma.ru/public/AdGuardHome.git
synced 2026-08-04 17:49:13 +00:00
ADG-10295 add encryption settings page
This commit is contained in:
@@ -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 = () => {
|
||||
|
||||
564
client_v2/src/components/Encryption/Form.tsx
Normal file
564
client_v2/src/components/Encryption/Form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
14
client_v2/src/components/Encryption/Status/KeyStatus.tsx
Normal file
14
client_v2/src/components/Encryption/Status/KeyStatus.tsx
Normal 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')}
|
||||
/>
|
||||
);
|
||||
28
client_v2/src/components/Encryption/Status/StatusBlock.tsx
Normal file
28
client_v2/src/components/Encryption/Status/StatusBlock.tsx
Normal 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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
3
client_v2/src/components/Encryption/Status/index.ts
Normal file
3
client_v2/src/components/Encryption/Status/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CertificateStatus } from './CertificateStatus';
|
||||
export { KeyStatus } from './KeyStatus';
|
||||
export { ValidationStatus } from './ValidationStatus';
|
||||
@@ -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;
|
||||
}
|
||||
115
client_v2/src/components/Encryption/index.tsx
Normal file
115
client_v2/src/components/Encryption/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
client_v2/src/components/Encryption/styles.module.pcss
Normal file
16
client_v2/src/components/Encryption/styles.module.pcss
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { RadioGroup } from './RadioGroup';
|
||||
export { SwitchGroup } from './SwitchGroup';
|
||||
@@ -1,40 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user