diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 697dbbcd..d53593c5 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -298,5 +298,14 @@ "clients_not_found": "No clients found", "client_confirm_delete": "Are you sure you want to delete client \"{{key}}\"?", "auto_clients_title": "Clients (runtime)", - "auto_clients_desc": "Data on the clients that use AdGuard Home, but not stored in the configuration" -} \ No newline at end of file + "auto_clients_desc": "Data on the clients that use AdGuard Home, but not stored in the configuration", + "access_title": "Access settings", + "access_desc": "Here you can configure access rules for the AdGuard Home DNS server.", + "access_allowed_title": "Allowed clients", + "access_allowed_desc": "A list of CIDR or IP addresses. If configured, AdGuard Home will accept requests from these IP addresses only.", + "access_disallowed_title": "Disallowed clients", + "access_disallowed_desc": "A list of CIDR or IP addresses. If configured, AdGuard Home will drop requests from these IP addresses.", + "access_blocked_title": "Blocked domains", + "access_blocked_desc": "Don't confuse this with filters. AdGuard Home will drop DNS queries with these domains in query's question.", + "access_settings_saved": "Access settings successfully saved" +} diff --git a/client/src/actions/access.js b/client/src/actions/access.js new file mode 100644 index 00000000..b10062cb --- /dev/null +++ b/client/src/actions/access.js @@ -0,0 +1,45 @@ +import { createAction } from 'redux-actions'; +import Api from '../api/Api'; +import { addErrorToast, addSuccessToast } from './index'; +import { normalizeTextarea } from '../helpers/helpers'; + +const apiClient = new Api(); + +export const getAccessListRequest = createAction('GET_ACCESS_LIST_REQUEST'); +export const getAccessListFailure = createAction('GET_ACCESS_LIST_FAILURE'); +export const getAccessListSuccess = createAction('GET_ACCESS_LIST_SUCCESS'); + +export const getAccessList = () => async (dispatch) => { + dispatch(getAccessListRequest()); + try { + const data = await apiClient.getAccessList(); + dispatch(getAccessListSuccess(data)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getAccessListFailure()); + } +}; + +export const setAccessListRequest = createAction('SET_ACCESS_LIST_REQUEST'); +export const setAccessListFailure = createAction('SET_ACCESS_LIST_FAILURE'); +export const setAccessListSuccess = createAction('SET_ACCESS_LIST_SUCCESS'); + +export const setAccessList = config => async (dispatch) => { + dispatch(setAccessListRequest()); + try { + const { allowed_clients, disallowed_clients, blocked_hosts } = config; + + const values = { + allowed_clients: (allowed_clients && normalizeTextarea(allowed_clients)) || [], + disallowed_clients: (disallowed_clients && normalizeTextarea(disallowed_clients)) || [], + blocked_hosts: (blocked_hosts && normalizeTextarea(blocked_hosts)) || [], + }; + + await apiClient.setAccessList(values); + dispatch(setAccessListSuccess()); + dispatch(addSuccessToast('access_settings_saved')); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(setAccessListFailure()); + } +}; diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 81bce7cf..1fa852f2 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -460,4 +460,22 @@ export default class Api { }; return this.makeRequest(path, method, parameters); } + + // DNS access settings + ACCESS_LIST = { path: 'access/list', method: 'GET' }; + ACCESS_SET = { path: 'access/set', method: 'POST' }; + + getAccessList() { + const { path, method } = this.ACCESS_LIST; + return this.makeRequest(path, method); + } + + setAccessList(config) { + const { path, method } = this.ACCESS_SET; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } } diff --git a/client/src/components/Settings/Access/Form.js b/client/src/components/Settings/Access/Form.js new file mode 100644 index 00000000..9096102d --- /dev/null +++ b/client/src/components/Settings/Access/Form.js @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Field, reduxForm } from 'redux-form'; +import { Trans, withNamespaces } from 'react-i18next'; +import flow from 'lodash/flow'; + +const Form = (props) => { + const { handleSubmit, submitting, invalid } = props; + + return ( + <form onSubmit={handleSubmit}> + <div className="form__group mb-5"> + <label className="form__label form__label--with-desc" htmlFor="allowed_clients"> + <Trans>access_allowed_title</Trans> + </label> + <div className="form__desc form__desc--top"> + <Trans>access_allowed_desc</Trans> + </div> + <Field + id="allowed_clients" + name="allowed_clients" + component="textarea" + type="text" + className="form-control form-control--textarea" + /> + </div> + <div className="form__group mb-5"> + <label className="form__label form__label--with-desc" htmlFor="disallowed_clients"> + <Trans>access_disallowed_title</Trans> + </label> + <div className="form__desc form__desc--top"> + <Trans>access_disallowed_desc</Trans> + </div> + <Field + id="disallowed_clients" + name="disallowed_clients" + component="textarea" + type="text" + className="form-control form-control--textarea" + /> + </div> + <div className="form__group mb-5"> + <label className="form__label form__label--with-desc" htmlFor="blocked_hosts"> + <Trans>access_blocked_title</Trans> + </label> + <div className="form__desc form__desc--top"> + <Trans>access_blocked_desc</Trans> + </div> + <Field + id="blocked_hosts" + name="blocked_hosts" + component="textarea" + type="text" + className="form-control form-control--textarea" + /> + </div> + <div className="card-actions"> + <div className="btn-list"> + <button + type="submit" + className="btn btn-success btn-standard" + disabled={submitting || invalid} + > + <Trans>save_config</Trans> + </button> + </div> + </div> + </form> + ); +}; + +Form.propTypes = { + handleSubmit: PropTypes.func, + submitting: PropTypes.bool, + invalid: PropTypes.bool, + initialValues: PropTypes.object, + t: PropTypes.func, +}; + +export default flow([withNamespaces(), reduxForm({ form: 'accessForm' })])(Form); diff --git a/client/src/components/Settings/Access/index.js b/client/src/components/Settings/Access/index.js new file mode 100644 index 00000000..77ccc265 --- /dev/null +++ b/client/src/components/Settings/Access/index.js @@ -0,0 +1,43 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withNamespaces } from 'react-i18next'; + +import Form from './Form'; +import Card from '../../ui/Card'; + +class Access extends Component { + handleFormSubmit = (values) => { + this.props.setAccessList(values); + }; + + render() { + const { t, access } = this.props; + + const { + processing, + processingSet, + ...values + } = access; + + return ( + <Card + title={t('access_title')} + subtitle={t('access_desc')} + bodyType="card-body box-body--settings" + > + <Form + initialValues={values} + onSubmit={this.handleFormSubmit} + /> + </Card> + ); + } +} + +Access.propTypes = { + access: PropTypes.object.isRequired, + setAccessList: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, +}; + +export default withNamespaces()(Access); diff --git a/client/src/components/Settings/Settings.css b/client/src/components/Settings/Settings.css index 281330df..7e410a0c 100644 --- a/client/src/components/Settings/Settings.css +++ b/client/src/components/Settings/Settings.css @@ -63,6 +63,10 @@ font-weight: 700; } +.form__label--with-desc { + margin-bottom: 0; +} + .form__status { margin-top: 10px; font-size: 14px; diff --git a/client/src/components/Settings/Upstream/Form.js b/client/src/components/Settings/Upstream/Form.js index 8ef916f5..37990e42 100644 --- a/client/src/components/Settings/Upstream/Form.js +++ b/client/src/components/Settings/Upstream/Form.js @@ -62,7 +62,7 @@ let Form = (props) => { </div> <div className="col-12"> <div className="form__group"> - <label className="form__label" htmlFor="bootstrap_dns"> + <label className="form__label form__label--with-desc" htmlFor="bootstrap_dns"> <Trans>bootstrap_dns</Trans> </label> <div className="form__desc form__desc--top"> diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js index 8d36c6c4..2bafa425 100644 --- a/client/src/components/Settings/index.js +++ b/client/src/components/Settings/index.js @@ -7,6 +7,7 @@ import Dhcp from './Dhcp'; import Encryption from './Encryption'; import Clients from './Clients'; import AutoClients from './Clients/AutoClients'; +import Access from './Access'; import Checkbox from '../ui/Checkbox'; import Loading from '../ui/Loading'; import PageTitle from '../ui/PageTitle'; @@ -43,6 +44,7 @@ class Settings extends Component { this.props.getDhcpStatus(); this.props.getDhcpInterfaces(); this.props.getTlsStatus(); + this.props.getAccessList(); } renderSettings = (settings) => { @@ -68,7 +70,7 @@ class Settings extends Component { render() { const { - settings, dashboard, clients, t, + settings, dashboard, clients, access, t, } = this.props; return ( <Fragment> @@ -117,6 +119,7 @@ class Settings extends Component { /> </Fragment> )} + <Access access={access} setAccessList={this.props.setAccessList} /> <Encryption encryption={this.props.encryption} setTlsConfig={this.props.setTlsConfig} diff --git a/client/src/containers/Settings.js b/client/src/containers/Settings.js index 0255e044..5959d5fb 100644 --- a/client/src/containers/Settings.js +++ b/client/src/containers/Settings.js @@ -26,6 +26,10 @@ import { deleteClient, toggleClientModal, } from '../actions/clients'; +import { + getAccessList, + setAccessList, +} from '../actions/access'; import Settings from '../components/Settings'; const mapStateToProps = (state) => { @@ -35,6 +39,7 @@ const mapStateToProps = (state) => { dhcp, encryption, clients, + access, } = state; const props = { settings, @@ -42,6 +47,7 @@ const mapStateToProps = (state) => { dhcp, encryption, clients, + access, }; return props; }; @@ -68,6 +74,8 @@ const mapDispatchToProps = { addStaticLease, removeStaticLease, toggleLeaseModal, + getAccessList, + setAccessList, }; export default connect( diff --git a/client/src/reducers/access.js b/client/src/reducers/access.js new file mode 100644 index 00000000..9ee258a0 --- /dev/null +++ b/client/src/reducers/access.js @@ -0,0 +1,43 @@ +import { handleActions } from 'redux-actions'; + +import * as actions from '../actions/access'; + +const access = handleActions( + { + [actions.getAccessListRequest]: state => ({ ...state, processing: true }), + [actions.getAccessListFailure]: state => ({ ...state, processing: false }), + [actions.getAccessListSuccess]: (state, { payload }) => { + const { + allowed_clients, + disallowed_clients, + blocked_hosts, + } = payload; + const newState = { + ...state, + allowed_clients: allowed_clients.join('\n'), + disallowed_clients: disallowed_clients.join('\n'), + blocked_hosts: blocked_hosts.join('\n'), + }; + return newState; + }, + + [actions.setAccessListRequest]: state => ({ ...state, processingSet: true }), + [actions.setAccessListFailure]: state => ({ ...state, processingSet: false }), + [actions.setAccessListSuccess]: (state) => { + const newState = { + ...state, + processingSet: false, + }; + return newState; + }, + }, + { + processing: true, + processingSet: false, + allowed_clients: null, + disallowed_clients: null, + blocked_hosts: null, + }, +); + +export default access; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index e9a012f8..7e46f660 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -8,6 +8,7 @@ import * as actions from '../actions'; import toasts from './toasts'; import encryption from './encryption'; import clients from './clients'; +import access from './access'; const settings = handleActions({ [actions.initSettingsRequest]: state => ({ ...state, processing: true }), @@ -418,6 +419,7 @@ export default combineReducers({ dhcp, encryption, clients, + access, loadingBar: loadingBarReducer, form: formReducer, });