diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index e8e984e2..6cce0d91 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -194,6 +194,10 @@ "dns_test_not_ok_toast": "Server \"{{key}}\": could not be used, please check that you've written it correctly", "unblock": "Unblock", "block": "Block", + "disallow_this_client": "Disallow this client", + "allow_this_client": "Allow this client", + "block_for_this_client_only": "Block for this client only", + "unblock_for_this_client_only": "Unblock for this client only", "time_table_header": "Time", "date": "Date", "domain_name_table_header": "Domain name", @@ -569,5 +573,6 @@ "setup_config_to_enable_dhcp_server": "Setup config to enable DHCP server", "original_response": "Original response", "click_to_view_queries": "Click to view queries", - "port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <0>this instruction on how to resolve this." + "port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <0>this instruction on how to resolve this.", + "adg_will_drop_dns_queries": "AdGuard Home will be dropping all DNS queries from this client." } diff --git a/client/src/actions/index.js b/client/src/actions/index.js index ff512883..d4018bb0 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -545,15 +545,17 @@ export const removeStaticLease = (config) => async (dispatch) => { export const removeToast = createAction('REMOVE_TOAST'); -export const toggleBlocking = (type, domain) => async (dispatch, getState) => { +export const toggleBlocking = ( + type, domain, baseRule, baseUnblocking, +) => async (dispatch, getState) => { + const baseBlockingRule = baseRule || `||${domain}^$important`; + const baseUnblockingRule = baseUnblocking || `@@${baseBlockingRule}`; const { userRules } = getState().filtering; const lineEnding = !endsWith(userRules, '\n') ? '\n' : ''; - const baseRule = `||${domain}^$important`; - const baseUnblocking = `@@${baseRule}`; - const blockingRule = type === BLOCK_ACTIONS.BLOCK ? baseUnblocking : baseRule; - const unblockingRule = type === BLOCK_ACTIONS.BLOCK ? baseRule : baseUnblocking; + const blockingRule = type === BLOCK_ACTIONS.BLOCK ? baseUnblockingRule : baseBlockingRule; + const unblockingRule = type === BLOCK_ACTIONS.BLOCK ? baseBlockingRule : baseUnblockingRule; const preparedBlockingRule = new RegExp(`(^|\n)${escapeRegExp(blockingRule)}($|\n)`); const preparedUnblockingRule = new RegExp(`(^|\n)${escapeRegExp(unblockingRule)}($|\n)`); @@ -576,3 +578,10 @@ export const toggleBlocking = (type, domain) => async (dispatch, getState) => { dispatch(getFilteringStatus()); }; + +export const toggleBlockingForClient = (type, domain, client) => { + const baseRule = `||${domain}^$client='${client.replace(/'/g, '/\'')}'`; + const baseUnblocking = `@@${baseRule}`; + + return toggleBlocking(type, domain, baseRule, baseUnblocking); +}; diff --git a/client/src/components/App/index.css b/client/src/components/App/index.css index 091a6612..e2b0304d 100644 --- a/client/src/components/App/index.css +++ b/client/src/components/App/index.css @@ -66,3 +66,12 @@ body { .select--no-warning { margin-bottom: 1.375rem; } + +.button-action { + visibility: hidden; +} + +.logs__row:hover .button-action, +.button-action--active { + visibility: visible; +} diff --git a/client/src/components/Dashboard/Clients.js b/client/src/components/Dashboard/Clients.js index 24b278f4..3c163035 100644 --- a/client/src/components/Dashboard/Clients.js +++ b/client/src/components/Dashboard/Clients.js @@ -51,15 +51,16 @@ const renderBlockingButton = (ip) => { const type = isNotFound ? BLOCK_ACTIONS.BLOCK : BLOCK_ACTIONS.UNBLOCK; const text = type; - const className = classNames('btn btn-sm', { - 'btn-outline-danger': isNotFound, - 'btn-outline-secondary': !isNotFound, + const buttonClass = classNames('button-action button-action--main', { + 'button-action--unblock': !isNotFound, }); const toggleClientStatus = (type, ip) => { - const confirmMessage = type === BLOCK_ACTIONS.BLOCK ? 'client_confirm_block' : 'client_confirm_unblock'; + const confirmMessage = type === BLOCK_ACTIONS.BLOCK + ? `${t('adg_will_drop_dns_queries')} ${t('client_confirm_block', { ip })}` + : t('client_confirm_unblock', { ip }); - if (window.confirm(t(confirmMessage, { ip }))) { + if (window.confirm(confirmMessage)) { dispatch(toggleClientBlock(type, ip)); } }; @@ -69,7 +70,7 @@ const renderBlockingButton = (ip) => { return
; + const getOptions = (optionToActionMap) => { + const options = Object.entries(optionToActionMap); + if (options.length === 0) { + return null; + } + return <>{options + .map(([name, onClick]) =>
{t(name)} +
)}; + }; + + const content = getOptions(BUTTON_OPTIONS_TO_ACTION_MAP); + + const buttonClass = classNames('button-action button-action--main', { + 'button-action--unblock': isFiltered, + 'button-action--with-options': content, + 'button-action--active': isOptionsOpened, + }); + + const buttonArrowClass = classNames('button-action button-action--arrow', { + 'button-action--unblock': isFiltered, + 'button-action--active': isOptionsOpened, + }); + + const containerClass = classNames('button-action__container', { + 'button-action__container--detailed': isDetailed, + }); + + return
+ + {content && } +
; }; return
@@ -81,9 +148,7 @@ const ClientCell = ({
{isDetailed && name && !whoisAvailable &&
- {name} -
} + title={name}>{name}
} {renderBlockingButton(isFiltered, domain)} ; diff --git a/client/src/components/Logs/Cells/IconTooltip.js b/client/src/components/Logs/Cells/IconTooltip.js index 5b9cc2cb..8bb3d624 100644 --- a/client/src/components/Logs/Cells/IconTooltip.js +++ b/client/src/components/Logs/Cells/IconTooltip.js @@ -6,17 +6,21 @@ import { processContent } from '../../../helpers/helpers'; import Tooltip from '../../ui/Tooltip'; import 'react-popper-tooltip/dist/styles.css'; import './IconTooltip.css'; +import { SHOW_TOOLTIP_DELAY } from '../../../helpers/constants'; const IconTooltip = ({ className, contentItemClass, columnClass, + triggerClass, canShowTooltip = true, xlinkHref, title, placement, tooltipClass, content, + trigger, + onVisibilityChange, renderContent = content ? React.Children.map( processContent(content), (item, idx) =>
@@ -36,6 +40,10 @@ const IconTooltip = ({ className={tooltipClassName} content={tooltipContent} placement={placement} + triggerClass={triggerClass} + trigger={trigger} + onVisibilityChange={onVisibilityChange} + delayShow={trigger === 'click' ? 0 : SHOW_TOOLTIP_DELAY} > {xlinkHref && @@ -45,6 +53,8 @@ const IconTooltip = ({ IconTooltip.propTypes = { className: PropTypes.string, + trigger: PropTypes.string, + triggerClass: PropTypes.string, contentItemClass: PropTypes.string, columnClass: PropTypes.string, tooltipClass: PropTypes.string, @@ -52,11 +62,9 @@ IconTooltip.propTypes = { placement: PropTypes.string, canShowTooltip: PropTypes.bool, xlinkHref: PropTypes.string, - content: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.array, - ]), + content: PropTypes.node, renderContent: PropTypes.arrayOf(PropTypes.element), + onVisibilityChange: PropTypes.func, }; export default IconTooltip; diff --git a/client/src/components/Logs/Cells/helpers/index.js b/client/src/components/Logs/Cells/helpers/index.js new file mode 100644 index 00000000..61e7ff5c --- /dev/null +++ b/client/src/components/Logs/Cells/helpers/index.js @@ -0,0 +1,19 @@ +import { getIpMatchListStatus } from '../../../../helpers/helpers'; +import { BLOCK_ACTIONS, IP_MATCH_LIST_STATUS } from '../../../../helpers/constants'; + +export const BUTTON_PREFIX = 'btn_'; + +export const getBlockClientInfo = (client, disallowed_clients) => { + const ipMatchListStatus = getIpMatchListStatus(client, disallowed_clients); + + const isNotFound = ipMatchListStatus === IP_MATCH_LIST_STATUS.NOT_FOUND; + const type = isNotFound ? BLOCK_ACTIONS.BLOCK : BLOCK_ACTIONS.UNBLOCK; + + const confirmMessage = isNotFound ? 'client_confirm_block' : 'client_confirm_unblock'; + const buttonKey = isNotFound ? 'disallow_this_client' : 'allow_this_client'; + return { + confirmMessage, + buttonKey, + type, + }; +}; diff --git a/client/src/components/Logs/Cells/index.js b/client/src/components/Logs/Cells/index.js index c2d7968b..8a0fced3 100644 --- a/client/src/components/Logs/Cells/index.js +++ b/client/src/components/Logs/Cells/index.js @@ -9,6 +9,7 @@ import { formatDateTime, formatElapsedMs, formatTime, + getBlockingClientName, getFilterName, processContent, } from '../../../helpers/helpers'; @@ -22,12 +23,14 @@ import { SCHEME_TO_PROTOCOL_MAP, } from '../../../helpers/constants'; import { getSourceData } from '../../../helpers/trackers/trackers'; -import { toggleBlocking } from '../../../actions'; +import { toggleBlocking, toggleBlockingForClient } from '../../../actions'; import DateCell from './DateCell'; import DomainCell from './DomainCell'; import ResponseCell from './ResponseCell'; import ClientCell from './ClientCell'; import '../Logs.css'; +import { toggleClientBlock } from '../../../actions/access'; +import { getBlockClientInfo, BUTTON_PREFIX } from './helpers'; const Row = memo(({ style, @@ -45,6 +48,13 @@ const Row = memo(({ const whitelistFilters = useSelector((state) => state.filtering.whitelistFilters, shallowEqual); const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual); + const disallowed_clients = useSelector( + (state) => state.access.disallowed_clients, + shallowEqual, + ); + + const clients = useSelector((state) => state.dashboard.clients); + const onClick = () => { if (!isSmallScreen) { return; } const { @@ -98,6 +108,26 @@ const Row = memo(({ const filter = getFilterName(filters, whitelistFilters, filterId); + const { + confirmMessage, + buttonKey: blockingClientKey, + type: blockType, + } = getBlockClientInfo(client, disallowed_clients); + + const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only'; + const clientNameBlockingFor = getBlockingClientName(clients, client); + + const onBlockingForClientClick = () => { + dispatch(toggleBlockingForClient(buttonType, domain, clientNameBlockingFor)); + }; + + const onBlockingClientClick = () => { + const message = `${blockType === BLOCK_ACTIONS.BLOCK ? t('adg_will_drop_dns_queries') : ''} ${t(confirmMessage, { ip: client })}`; + if (window.confirm(message)) { + dispatch(toggleClientBlock(blockType, client)); + } + }; + const detailedData = { time_table_header: formatTime(time, LONG_TIME_FORMAT), date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS), @@ -132,10 +162,12 @@ const Row = memo(({ source_label: source, validated_with_dnssec: dnssec_enabled ? Boolean(answer_dnssec) : false, original_response: originalResponse?.join('\n'), - [buttonType]:
{t(buttonType)}
, + [BUTTON_PREFIX + buttonType]:
{t(buttonType)}
, + [BUTTON_PREFIX + blockingForClientKey]:
{t(blockingForClientKey)}
, + [BUTTON_PREFIX + blockingClientKey]:
{t(blockingClientKey)}
, }; setDetailedDataCurrent(processContent(detailedData)); diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css index 857fd466..fd088b79 100644 --- a/client/src/components/Logs/Logs.css +++ b/client/src/components/Logs/Logs.css @@ -10,9 +10,20 @@ --size-client: 123; --gray-216: rgba(216, 216, 216, 0.23); --gray-4d: #4D4D4D; + --gray-f3: #F3F3F3; --gray-8: #888; --danger: #DF3812; --white80: rgba(255, 255, 255, 0.8); + + --btn-block: #C23814; + --btn-block-disabled: #E3B3A6; + --btn-block-active: #A62200; + + --btn-unblock: #888888; + --btn-unblock-disabled: #D8D8D8; + --btn-unblock-active: #4D4D4D; + + --option-border-radius: 4px; } .logs__text { @@ -191,6 +202,7 @@ width: 7.6875rem; flex: var(--size-client) 0 auto; padding-right: 0; + position: relative; } .logs__cell--header__container > .logs__cell--header__item { @@ -202,12 +214,95 @@ padding-right: 0; } -.logs__cell--block-button { - max-height: 1.75rem; - position: relative; - left: 10%; - top: 40%; - visibility: hidden; +.button-action__container { + display: flex; + position: absolute; + right: 0; + bottom: 0.5rem; + height: 1.6rem; +} + +.button-action__container--detailed { + bottom: 1.3rem; +} + +.button-action { + outline: 0 !important; + background: var(--btn-block); + border-radius: var(--option-border-radius); + font-size: 0.8rem; + color: var(--white); + letter-spacing: 0; + text-align: center; + line-height: 28px; + border: 0; +} + +.button-action--unblock { + background: var(--btn-unblock); +} + +.button-action--main { + padding: 0 1rem; + display: flex; + align-items: center; +} + +.button-action--with-options { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.button-action--arrow { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 1px solid var(--white); + width: 1.5625rem; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.button-action:hover { + cursor: pointer; +} + +.button-action--arrow .button-action--icon { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.button-action:active { + background: var(--btn-block-active); +} + +.button-action--unblock:active { + background: var(--btn-unblock-active); +} + +.button-action:disabled { + background: var(--btn-block-disabled); + cursor: default; +} + +.button-action--unblock:disabled { + background: var(--btn-unblock-disabled); +} + +.button-action--arrow-option:hover { + cursor: pointer; + background: var(--gray-f3); + overflow: hidden; +} + +.button-action--arrow-option-container { + overflow: visible; + transform-origin: left; + padding: 1rem 0; } .logs__row { @@ -222,14 +317,6 @@ border-bottom: 2px solid var(--gray-216); } -.logs__table .logs__row:hover .logs__cell--block-button { - visibility: visible; -} - -.logs__table .logs__row .logs__cell--block-button:disabled { - background-color: var(--white) !important; -} - /* QUERY_STATUS_COLORS */ .logs__row--blue { background-color: var(--blue); diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js index 13fa697c..bcc9a94d 100644 --- a/client/src/components/Logs/index.js +++ b/client/src/components/Logs/index.js @@ -23,15 +23,16 @@ import { } from '../../actions/queryLogs'; import InfiniteTable from './InfiniteTable'; import './Logs.css'; +import { BUTTON_PREFIX } from './Cells/helpers'; -const processContent = (data, buttonType) => Object.entries(data) +const processContent = (data) => Object.entries(data) .map(([key, value]) => { if (!value) { return null; } const isTitle = value === 'title'; - const isButton = key === buttonType; + const isButton = key.startsWith(BUTTON_PREFIX); const isBoolean = typeof value === 'boolean'; const isHidden = isBoolean && value === false; diff --git a/client/src/components/ui/Icons.js b/client/src/components/ui/Icons.js index f29bcc21..3851ff01 100644 --- a/client/src/components/ui/Icons.js +++ b/client/src/components/ui/Icons.js @@ -344,6 +344,14 @@ const Icons = () => ( + + + + + + + ); diff --git a/client/src/components/ui/Tooltip.js b/client/src/components/ui/Tooltip.js index 9f34b3fe..87b353de 100644 --- a/client/src/components/ui/Tooltip.js +++ b/client/src/components/ui/Tooltip.js @@ -20,6 +20,7 @@ const Tooltip = ({ trigger = 'hover', delayShow = SHOW_TOOLTIP_DELAY, delayHide = HIDE_TOOLTIP_DELAY, + onVisibilityChange, }) => { const { t } = useTranslation(); const touchEventsAvailable = 'ontouchstart' in window; @@ -73,6 +74,7 @@ const Tooltip = ({ delayHide={delayHideValue} delayShow={delayShowValue} tooltip={renderTooltip} + onVisibilityChange={onVisibilityChange} > {renderTrigger} @@ -90,10 +92,11 @@ Tooltip.propTypes = { ).isRequired, placement: propTypes.string, trigger: propTypes.string, - delayHide: propTypes.string, - delayShow: propTypes.string, + delayHide: propTypes.number, + delayShow: propTypes.number, className: propTypes.string, triggerClass: propTypes.string, + onVisibilityChange: propTypes.func, }; export default Tooltip; diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js index fa3a5046..bafd230e 100644 --- a/client/src/helpers/helpers.js +++ b/client/src/helpers/helpers.js @@ -836,3 +836,21 @@ export const isScrolledIntoView = (el) => { return elemTop < window.innerHeight && elemBottom >= 0; }; + +/** + * If this is a manually created client, return its name. + * If this is a "runtime" client, return it's IP address. + * @param clients {Array.} + * @param ip {string} + * @returns {string} + */ +export const getBlockingClientName = (clients, ip) => { + for (let i = 0; i < clients.length; i += 1) { + const client = clients[i]; + + if (client.ids.includes(ip)) { + return client.name; + } + } + return ip; +};