Skip to content

Commit b3315da

Browse files
client: add per-client filter lists ui
Add a new "Filter lists" tab in the client settings modal allowing admins to toggle specific blocklists and allowlists per client. When "Use global filter lists" is off, individual filters can be selected. The layout uses a two-column grid for filter entries.
1 parent 944544d commit b3315da

10 files changed

Lines changed: 274 additions & 93 deletions

File tree

client/package-lock.json

Lines changed: 56 additions & 92 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/__locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@
108108
"client_deleted": "Client \"{{key}}\" successfully deleted",
109109
"client_details": "Client details",
110110
"client_edit": "Edit Client",
111+
"client_filter_lists": "Filter lists",
112+
"client_filter_lists_desc": "Select which blocklists and allowlists apply to this client.",
111113
"client_global_settings": "Use global settings",
112114
"client_id": "ClientID",
113115
"client_id_desc": "Clients can be identified by ClientID. Learn more about how to identify clients <a>here</a>.",
@@ -724,6 +726,9 @@
724726
"unavailable_dhcp_desc": "AdGuard Home cannot run a DHCP server on your OS",
725727
"unblock": "Unblock",
726728
"unblock_all": "Unblock all",
729+
"use_global_filter_lists": "Use global filter lists",
730+
"select_all": "Select all",
731+
"deselect_all": "Deselect all",
727732
"unblock_for_this_client_only": "Unblock for this client only",
728733
"unknown_filter": "Unknown filter {{filterId}}",
729734
"update_announcement": "AdGuard Home {{version}} is now available! <0>Click here</0> for more info.",

client/src/components/Settings/Clients/ClientsTable/ClientsTable.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ const ClientsTable = ({
120120
if (typeof values.upstreams_cache_size === 'string') {
121121
config.upstreams_cache_size = 0;
122122
}
123+
124+
if (values.filter_list_ids) {
125+
config.filter_list_ids = Object.keys(values.filter_list_ids)
126+
.filter((id) => values.filter_list_ids[Number(id)])
127+
.map(Number);
128+
}
129+
130+
if (values.allow_filter_list_ids) {
131+
config.allow_filter_list_ids = Object.keys(values.allow_filter_list_ids)
132+
.filter((id) => values.allow_filter_list_ids[Number(id)])
133+
.map(Number);
134+
}
123135
}
124136

125137
if (modalType === MODAL_TYPE.EDIT_CLIENT) {
@@ -156,6 +168,7 @@ const ClientsTable = ({
156168
tags: [],
157169
use_global_settings: true,
158170
use_global_blocked_services: true,
171+
use_global_filter_lists: true,
159172
blocked_services_schedule: {
160173
time_zone: LOCAL_TIMEZONE_VALUE,
161174
},
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { useEffect } from 'react';
2+
import { Trans, useTranslation } from 'react-i18next';
3+
import { Controller, useFormContext } from 'react-hook-form';
4+
import { useDispatch, useSelector } from 'react-redux';
5+
import { ClientForm } from '../types';
6+
import { ServiceField } from '../../../../Filters/Services/ServiceField';
7+
import { RootState } from '../../../../../initialState';
8+
import { getFilteringStatus } from '../../../../../actions/filtering';
9+
import { Filter } from '../../../../../helpers/helpers';
10+
11+
export const FilterLists = () => {
12+
const { t } = useTranslation();
13+
const dispatch = useDispatch();
14+
const { watch, setValue, control } = useFormContext<ClientForm>();
15+
16+
const useGlobalFilterLists = watch('use_global_filter_lists');
17+
18+
const filters: Filter[] = useSelector((state: RootState) => state?.filtering?.filters) || [];
19+
const whitelistFilters: Filter[] =
20+
useSelector((state: RootState) => state?.filtering?.whitelistFilters) || [];
21+
22+
useEffect(() => {
23+
dispatch(getFilteringStatus());
24+
}, [dispatch]);
25+
26+
const handleToggleAllBlocklists = (isSelected: boolean) => {
27+
filters.forEach((filter) => setValue(`filter_list_ids.${filter.id}`, isSelected));
28+
};
29+
30+
const handleToggleAllAllowlists = (isSelected: boolean) => {
31+
whitelistFilters.forEach((filter) => setValue(`allow_filter_list_ids.${filter.id}`, isSelected));
32+
};
33+
34+
return (
35+
<div title={t('client_filter_lists')}>
36+
<div className="form__group">
37+
<Controller
38+
name="use_global_filter_lists"
39+
control={control}
40+
render={({ field }) => (
41+
<ServiceField
42+
{...field}
43+
data-testid="clients_use_global_filter_lists"
44+
placeholder={t('use_global_filter_lists')}
45+
className="service--global"
46+
/>
47+
)}
48+
/>
49+
50+
<div className="form__desc mt-0 mb-2">
51+
<Trans>client_filter_lists_desc</Trans>
52+
</div>
53+
54+
{filters.length > 0 && (
55+
<>
56+
<div className="form__label mt-3">
57+
<strong>
58+
<Trans>dns_blocklists</Trans>
59+
</strong>
60+
</div>
61+
62+
<div className="row mb-2">
63+
<div className="col-6">
64+
<button
65+
type="button"
66+
className="btn btn-secondary btn-block btn-sm"
67+
disabled={useGlobalFilterLists}
68+
onClick={() => handleToggleAllBlocklists(true)}>
69+
<Trans>select_all</Trans>
70+
</button>
71+
</div>
72+
73+
<div className="col-6">
74+
<button
75+
type="button"
76+
className="btn btn-secondary btn-block btn-sm"
77+
disabled={useGlobalFilterLists}
78+
onClick={() => handleToggleAllBlocklists(false)}>
79+
<Trans>deselect_all</Trans>
80+
</button>
81+
</div>
82+
</div>
83+
84+
<div className="services services--half">
85+
{filters.map((filter: Filter) => (
86+
<Controller
87+
key={filter.id}
88+
name={`filter_list_ids.${filter.id}`}
89+
control={control}
90+
render={({ field }) => (
91+
<ServiceField
92+
{...field}
93+
data-testid={`clients_filter_${filter.id}`}
94+
placeholder={filter.name}
95+
disabled={useGlobalFilterLists}
96+
/>
97+
)}
98+
/>
99+
))}
100+
</div>
101+
</>
102+
)}
103+
104+
{whitelistFilters.length > 0 && (
105+
<>
106+
<div className="form__label mt-3">
107+
<strong>
108+
<Trans>dns_allowlists</Trans>
109+
</strong>
110+
</div>
111+
112+
<div className="row mb-2">
113+
<div className="col-6">
114+
<button
115+
type="button"
116+
className="btn btn-secondary btn-block btn-sm"
117+
disabled={useGlobalFilterLists}
118+
onClick={() => handleToggleAllAllowlists(true)}>
119+
<Trans>select_all</Trans>
120+
</button>
121+
</div>
122+
123+
<div className="col-6">
124+
<button
125+
type="button"
126+
className="btn btn-secondary btn-block btn-sm"
127+
disabled={useGlobalFilterLists}
128+
onClick={() => handleToggleAllAllowlists(false)}>
129+
<Trans>deselect_all</Trans>
130+
</button>
131+
</div>
132+
</div>
133+
134+
<div className="services services--half">
135+
{whitelistFilters.map((filter: Filter) => (
136+
<Controller
137+
key={filter.id}
138+
name={`allow_filter_list_ids.${filter.id}`}
139+
control={control}
140+
render={({ field }) => (
141+
<ServiceField
142+
{...field}
143+
data-testid={`clients_allowfilter_${filter.id}`}
144+
placeholder={filter.name}
145+
disabled={useGlobalFilterLists}
146+
/>
147+
)}
148+
/>
149+
))}
150+
</div>
151+
</>
152+
)}
153+
</div>
154+
</div>
155+
);
156+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { BlockedServices } from './BlockedServices';
22
export { ClientIds } from './ClientIds';
3+
export { FilterLists } from './FilterLists';
34
export { ScheduleServices } from './ScheduleServices';
45
export { MainSettings } from './MainSettings';
56
export { UpstreamDns } from './UpstreamDns';

client/src/components/Settings/Clients/Form/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { RootState } from '../../../../initialState';
1010
import { Input } from '../../../ui/Controls/Input';
1111
import { validateRequiredValue } from '../../../../helpers/validators';
1212
import { ClientForm } from './types';
13-
import { BlockedServices, ClientIds, MainSettings, ScheduleServices, UpstreamDns } from './components';
13+
import { BlockedServices, ClientIds, FilterLists, MainSettings, ScheduleServices, UpstreamDns } from './components';
1414

1515
import '../Service.css';
1616

@@ -25,6 +25,9 @@ const defaultFormValues: ClientForm = {
2525
ignore_querylog: false,
2626
ignore_statistics: false,
2727
blocked_services: {},
28+
use_global_filter_lists: true,
29+
filter_list_ids: {},
30+
allow_filter_list_ids: {},
2831
safe_search: { enabled: false },
2932
upstreams: '',
3033
upstreams_cache_enabled: false,
@@ -89,6 +92,10 @@ export const Form = ({
8992
title: 'block_services',
9093
component: <BlockedServices services={services?.allServices} />,
9194
},
95+
client_filter_lists: {
96+
title: 'client_filter_lists',
97+
component: <FilterLists />,
98+
},
9299
schedule_services: {
93100
title: 'schedule_services',
94101
component: <ScheduleServices />,

client/src/components/Settings/Clients/Form/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export type ClientForm = {
1515
upstreams_cache_enabled: boolean;
1616
upstreams_cache_size: number;
1717
blocked_services: Record<string, boolean>;
18+
use_global_filter_lists: boolean;
19+
filter_list_ids: Record<string, boolean>;
20+
allow_filter_list_ids: Record<string, boolean>;
1821
filtering_enabled: boolean;
1922
safebrowsing_enabled: boolean;
2023
parental_enabled: boolean;

client/src/components/Settings/Clients/Modal.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ const normalizeIds = (initialIds?: string[]): { name: string }[] => {
1414
return initialIds.map((id: string) => ({ name: id }));
1515
};
1616

17+
const arrayToRecord = (arr: number[] | undefined): Record<number, boolean> => {
18+
const record: Record<number, boolean> = {};
19+
if (arr) {
20+
arr.forEach((id) => {
21+
record[id] = true;
22+
});
23+
}
24+
25+
return record;
26+
};
27+
1728
const getInitialData = ({ initial, modalType, clientId, clientName }: any) => {
1829
if (initial && initial.blocked_services) {
1930
const { blocked_services } = initial;
@@ -26,6 +37,8 @@ const getInitialData = ({ initial, modalType, clientId, clientName }: any) => {
2637
return {
2738
...initial,
2839
blocked_services: blocked,
40+
filter_list_ids: arrayToRecord(initial.filter_list_ids),
41+
allow_filter_list_ids: arrayToRecord(initial.allow_filter_list_ids),
2942
ids: normalizeIds(initial.ids),
3043
};
3144
}

client/src/components/Settings/Clients/Service.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@
4444
margin-right: 0;
4545
margin-left: auto;
4646
}
47+
48+
.services--half .service {
49+
flex-basis: calc(99.9% * 6 / 12 - (30px - 30px * 6 / 12));
50+
max-width: calc(99.9% * 6 / 12 - (30px - 30px * 6 / 12));
51+
width: calc(99.9% * 6 / 12 - (30px - 30px * 6 / 12));
52+
}
53+
54+
.services--half .service:nth-child(3n) {
55+
margin-right: 30px;
56+
margin-left: 0;
57+
}
58+
59+
.services--half .service:nth-child(2n) {
60+
margin-right: 0;
61+
margin-left: auto;
62+
}
4763
}
4864

4965
.service__icon {

client/src/initialState.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export type Client = {
104104
upstreams_cache_size: number;
105105
use_global_blocked_services: boolean;
106106
use_global_settings: boolean;
107+
use_global_filter_lists: boolean;
108+
filter_list_ids: number[];
109+
allow_filter_list_ids: number[];
107110
};
108111

109112
export type AutoClient = {

0 commit comments

Comments
 (0)