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

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

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

View File

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

View File

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

View File

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

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