From 22d3c38df280528fcc54bfbb1854a96a5ceb5b86 Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <i.kamalov@adguard.com>
Date: Wed, 22 May 2019 17:59:57 +0300
Subject: [PATCH] + client: handle per-client settings

---
 client/src/__locales/en.json                  |  24 +-
 client/src/actions/clients.js                 |  86 ++++++
 client/src/actions/index.js                   |  50 ++--
 client/src/api/Api.js                         |  38 +++
 client/src/components/Logs/Logs.css           |   4 +
 .../src/components/Settings/Clients/Form.js   | 215 +++++++++++++++
 .../src/components/Settings/Clients/Modal.js  |  57 ++++
 .../src/components/Settings/Clients/index.js  | 252 ++++++++++++++++++
 client/src/components/Settings/index.js       |  50 +++-
 client/src/components/ui/Modal.css            |   8 +-
 client/src/containers/Settings.js             |  12 +
 client/src/helpers/constants.js               |  14 +-
 client/src/helpers/form.js                    |   9 +-
 client/src/helpers/helpers.js                 |  18 ++
 client/src/reducers/clients.js                |  63 +++++
 client/src/reducers/index.js                  |   3 +
 16 files changed, 863 insertions(+), 40 deletions(-)
 create mode 100644 client/src/actions/clients.js
 create mode 100644 client/src/components/Settings/Clients/Form.js
 create mode 100644 client/src/components/Settings/Clients/Modal.js
 create mode 100644 client/src/components/Settings/Clients/index.js
 create mode 100644 client/src/reducers/clients.js

diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index e0cf54e7..1235d10f 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -19,6 +19,7 @@
     "dhcp_config_saved": "Saved DHCP server config",
     "form_error_required": "Required field",
     "form_error_ip_format": "Invalid IPv4 format",
+    "form_error_mac_format": "Invalid MAC format",
     "form_error_positive": "Must be greater than 0",
     "dhcp_form_gateway_input": "Gateway IP",
     "dhcp_form_subnet_input": "Subnet mask",
@@ -105,6 +106,7 @@
     "rules_count_table_header": "Rules count",
     "last_time_updated_table_header": "Last time updated",
     "actions_table_header": "Actions",
+    "edit_table_action": "Edit",
     "delete_table_action": "Delete",
     "filters_and_hosts": "Filters and hosts blocklists",
     "filters_and_hosts_hint": "AdGuard Home understands basic adblock rules and hosts files syntax.",
@@ -263,5 +265,25 @@
     "dns_providers": "Here is a <0>list of known DNS providers</0> to choose from.",
     "update_now": "Update now",
     "update_failed": "Auto-update failed. Please <a href='https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#update'>follow the steps<\/a> to update manually.",
-    "processing_update": "Please wait, AdGuard Home is being updated"
+    "processing_update": "Please wait, AdGuard Home is being updated",
+    "clients_title": "Clients",
+    "clients_desc": "Configure devices connected to AdGuard Home",
+    "settings_global": "Global",
+    "settings_custom": "Custom",
+    "add_client": "Add Client",
+    "table_client": "Client",
+    "table_name": "Name",
+    "save_btn": "Save",
+    "client_new": "New Client",
+    "client_identifier": "Identifier",
+    "ip_address": "IP address",
+    "client_identifier_desc": "Clients can be identified by the IP address or MAC address. Please note, that using MAC as identifier is possible only if AdGuard Home is also a <0>DHCP server</0>",
+    "form_enter_ip": "Enter IP",
+    "form_enter_mac": "Enter MAC",
+    "form_client_name": "Enter client name",
+    "client_global_settings": "Use global settings",
+    "client_deleted": "Client \"{{key}}\" successfully deleted",
+    "client_added":  "Client \"{{key}}\" successfully added",
+    "client_updated":  "Client \"{{key}}\" successfully updated",
+    "table_statistics": "Statistics (last 24 hours)"
 }
\ No newline at end of file
diff --git a/client/src/actions/clients.js b/client/src/actions/clients.js
new file mode 100644
index 00000000..1947ad25
--- /dev/null
+++ b/client/src/actions/clients.js
@@ -0,0 +1,86 @@
+import { createAction } from 'redux-actions';
+import { t } from 'i18next';
+import Api from '../api/Api';
+import { addErrorToast, addSuccessToast, getClients } from './index';
+import { CLIENT_ID } from '../helpers/constants';
+
+const apiClient = new Api();
+
+export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL');
+
+export const addClientRequest = createAction('ADD_CLIENT_REQUEST');
+export const addClientFailure = createAction('ADD_CLIENT_FAILURE');
+export const addClientSuccess = createAction('ADD_CLIENT_SUCCESS');
+
+export const addClient = config => async (dispatch) => {
+    dispatch(addClientRequest());
+    try {
+        let data;
+        if (config.identifier === CLIENT_ID.MAC) {
+            const { ip, identifier, ...values } = config;
+
+            data = { ...values };
+        } else {
+            const { mac, identifier, ...values } = config;
+
+            data = { ...values };
+        }
+
+        await apiClient.addClient(data);
+        dispatch(addClientSuccess());
+        dispatch(toggleClientModal());
+        dispatch(addSuccessToast(t('client_added', { key: config.name })));
+        dispatch(getClients());
+    } catch (error) {
+        dispatch(toggleClientModal());
+        dispatch(addErrorToast({ error }));
+        dispatch(addClientFailure());
+    }
+};
+
+export const deleteClientRequest = createAction('DELETE_CLIENT_REQUEST');
+export const deleteClientFailure = createAction('DELETE_CLIENT_FAILURE');
+export const deleteClientSuccess = createAction('DELETE_CLIENT_SUCCESS');
+
+export const deleteClient = config => async (dispatch) => {
+    dispatch(deleteClientRequest());
+    try {
+        await apiClient.deleteClient(config);
+        dispatch(deleteClientSuccess());
+        dispatch(addSuccessToast(t('client_deleted', { key: config.name })));
+        dispatch(getClients());
+    } catch (error) {
+        dispatch(addErrorToast({ error }));
+        dispatch(deleteClientFailure());
+    }
+};
+
+export const updateClientRequest = createAction('UPDATE_CLIENT_REQUEST');
+export const updateClientFailure = createAction('UPDATE_CLIENT_FAILURE');
+export const updateClientSuccess = createAction('UPDATE_CLIENT_SUCCESS');
+
+export const updateClient = (config, name) => async (dispatch) => {
+    dispatch(updateClientRequest());
+    try {
+        let data;
+        if (config.identifier === CLIENT_ID.MAC) {
+            const { ip, identifier, ...values } = config;
+
+            data = { name, data: { ...values } };
+        } else {
+            const { mac, identifier, ...values } = config;
+
+            data = { name, data: { ...values } };
+        }
+
+        await apiClient.updateClient(data);
+        dispatch(updateClientSuccess());
+        dispatch(toggleClientModal());
+        dispatch(addSuccessToast(t('client_updated', { key: name })));
+        dispatch(getClients());
+    } catch (error) {
+        dispatch(toggleClientModal());
+        dispatch(addErrorToast({ error }));
+        dispatch(updateClientFailure());
+    }
+};
diff --git a/client/src/actions/index.js b/client/src/actions/index.js
index 070c9324..493a4140 100644
--- a/client/src/actions/index.js
+++ b/client/src/actions/index.js
@@ -4,7 +4,7 @@ import { t } from 'i18next';
 import { showLoading, hideLoading } from 'react-redux-loading-bar';
 import axios from 'axios';
 
-import { normalizeHistory, normalizeFilteringStatus, normalizeLogs, normalizeTextarea } from '../helpers/helpers';
+import { normalizeHistory, normalizeFilteringStatus, normalizeLogs, normalizeTextarea, sortClients } from '../helpers/helpers';
 import { SETTINGS_NAMES, CHECK_TIMEOUT } from '../helpers/constants';
 import Api from '../api/Api';
 
@@ -213,14 +213,36 @@ export const getClientsSuccess = createAction('GET_CLIENTS_SUCCESS');
 export const getClients = () => async (dispatch) => {
     dispatch(getClientsRequest());
     try {
-        const clients = await apiClient.getGlobalClients();
-        dispatch(getClientsSuccess(clients));
+        const clients = await apiClient.getClients();
+        const sortedClients = sortClients(clients);
+        dispatch(getClientsSuccess(sortedClients));
     } catch (error) {
         dispatch(addErrorToast({ error }));
         dispatch(getClientsFailure());
     }
 };
 
+export const getTopStatsRequest = createAction('GET_TOP_STATS_REQUEST');
+export const getTopStatsFailure = createAction('GET_TOP_STATS_FAILURE');
+export const getTopStatsSuccess = createAction('GET_TOP_STATS_SUCCESS');
+
+export const getTopStats = () => async (dispatch, getState) => {
+    dispatch(getTopStatsRequest());
+    const timer = setInterval(async () => {
+        const state = getState();
+        if (state.dashboard.isCoreRunning) {
+            clearInterval(timer);
+            try {
+                const stats = await apiClient.getGlobalStatsTop();
+                dispatch(getTopStatsSuccess(stats));
+            } catch (error) {
+                dispatch(addErrorToast({ error }));
+                dispatch(getTopStatsFailure(error));
+            }
+        }
+    }, 100);
+};
+
 export const dnsStatusRequest = createAction('DNS_STATUS_REQUEST');
 export const dnsStatusFailure = createAction('DNS_STATUS_FAILURE');
 export const dnsStatusSuccess = createAction('DNS_STATUS_SUCCESS');
@@ -232,6 +254,7 @@ export const getDnsStatus = () => async (dispatch) => {
         dispatch(dnsStatusSuccess(dnsStatus));
         dispatch(getVersion());
         dispatch(getClients());
+        dispatch(getTopStats());
     } catch (error) {
         dispatch(addErrorToast({ error }));
         dispatch(initSettingsFailure());
@@ -289,27 +312,6 @@ export const getStats = () => async (dispatch) => {
     }
 };
 
-export const getTopStatsRequest = createAction('GET_TOP_STATS_REQUEST');
-export const getTopStatsFailure = createAction('GET_TOP_STATS_FAILURE');
-export const getTopStatsSuccess = createAction('GET_TOP_STATS_SUCCESS');
-
-export const getTopStats = () => async (dispatch, getState) => {
-    dispatch(getTopStatsRequest());
-    const timer = setInterval(async () => {
-        const state = getState();
-        if (state.dashboard.isCoreRunning) {
-            clearInterval(timer);
-            try {
-                const stats = await apiClient.getGlobalStatsTop();
-                dispatch(getTopStatsSuccess(stats));
-            } catch (error) {
-                dispatch(addErrorToast({ error }));
-                dispatch(getTopStatsFailure(error));
-            }
-        }
-    }, 100);
-};
-
 export const getLogsRequest = createAction('GET_LOGS_REQUEST');
 export const getLogsFailure = createAction('GET_LOGS_FAILURE');
 export const getLogsSuccess = createAction('GET_LOGS_SUCCESS');
diff --git a/client/src/api/Api.js b/client/src/api/Api.js
index 1743cc06..79abd2fb 100644
--- a/client/src/api/Api.js
+++ b/client/src/api/Api.js
@@ -409,4 +409,42 @@ export default class Api {
         };
         return this.makeRequest(path, method, parameters);
     }
+
+    // Per-client settings
+    GET_CLIENTS = { path: 'clients', method: 'GET' }
+    ADD_CLIENT = { path: 'clients/add', method: 'POST' }
+    DELETE_CLIENT = { path: 'clients/delete', method: 'POST' }
+    UPDATE_CLIENT = { path: 'clients/update', method: 'POST' }
+
+    getClients() {
+        const { path, method } = this.GET_CLIENTS;
+        return this.makeRequest(path, method);
+    }
+
+    addClient(config) {
+        const { path, method } = this.ADD_CLIENT;
+        const parameters = {
+            data: config,
+            headers: { 'Content-Type': 'application/json' },
+        };
+        return this.makeRequest(path, method, parameters);
+    }
+
+    deleteClient(config) {
+        const { path, method } = this.DELETE_CLIENT;
+        const parameters = {
+            data: config,
+            headers: { 'Content-Type': 'application/json' },
+        };
+        return this.makeRequest(path, method, parameters);
+    }
+
+    updateClient(config) {
+        const { path, method } = this.UPDATE_CLIENT;
+        const parameters = {
+            data: config,
+            headers: { 'Content-Type': 'application/json' },
+        };
+        return this.makeRequest(path, method, parameters);
+    }
 }
diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css
index d7df63bb..1b39f592 100644
--- a/client/src/components/Logs/Logs.css
+++ b/client/src/components/Logs/Logs.css
@@ -5,6 +5,10 @@
     min-height: 26px;
 }
 
+.logs__row--center {
+    justify-content: center;
+}
+
 .logs__row--overflow {
     overflow: hidden;
 }
diff --git a/client/src/components/Settings/Clients/Form.js b/client/src/components/Settings/Clients/Form.js
new file mode 100644
index 00000000..1874a746
--- /dev/null
+++ b/client/src/components/Settings/Clients/Form.js
@@ -0,0 +1,215 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { Field, reduxForm, formValueSelector } from 'redux-form';
+import { Trans, withNamespaces } from 'react-i18next';
+import flow from 'lodash/flow';
+
+import { renderField, renderSelectField, ipv4, mac, required } from '../../../helpers/form';
+import { CLIENT_ID } from '../../../helpers/constants';
+
+let Form = (props) => {
+    const {
+        t,
+        handleSubmit,
+        reset,
+        pristine,
+        submitting,
+        clientIdentifier,
+        useGlobalSettings,
+        toggleClientModal,
+        processingAdding,
+        processingUpdating,
+    } = props;
+
+    return (
+        <form onSubmit={handleSubmit}>
+            <div className="modal-body">
+                <div className="form__group">
+                    <div className="form-inline mb-3">
+                        <strong className="mr-3">
+                            <Trans>client_identifier</Trans>
+                        </strong>
+                        <label className="mr-3">
+                            <Field
+                                name="identifier"
+                                component={renderField}
+                                type="radio"
+                                className="form-control mr-2"
+                                value="ip"
+                            />{' '}
+                            <Trans>ip_address</Trans>
+                        </label>
+                        <label>
+                            <Field
+                                name="identifier"
+                                component={renderField}
+                                type="radio"
+                                className="form-control mr-2"
+                                value="mac"
+                            />{' '}
+                            MAC
+                        </label>
+                    </div>
+                    {clientIdentifier === CLIENT_ID.IP && (
+                        <div className="form__group">
+                            <Field
+                                id="ip"
+                                name="ip"
+                                component={renderField}
+                                type="text"
+                                className="form-control"
+                                placeholder={t('form_enter_ip')}
+                                validate={[ipv4, required]}
+                            />
+                        </div>
+                    )}
+                    {clientIdentifier === CLIENT_ID.MAC && (
+                        <div className="form__group">
+                            <Field
+                                id="mac"
+                                name="mac"
+                                component={renderField}
+                                type="text"
+                                className="form-control"
+                                placeholder={t('form_enter_mac')}
+                                validate={[mac, required]}
+                            />
+                        </div>
+                    )}
+                    <div className="form__desc">
+                        <Trans
+                            components={[
+                                <a href="#settings_dhcp" key="0">
+                                    link
+                                </a>,
+                            ]}
+                        >
+                            client_identifier_desc
+                        </Trans>
+                    </div>
+                </div>
+
+                <div className="form__group">
+                    <Field
+                        id="name"
+                        name="name"
+                        component={renderField}
+                        type="text"
+                        className="form-control"
+                        placeholder={t('form_client_name')}
+                        validate={[required]}
+                    />
+                </div>
+
+                <div className="mb-4">
+                    <strong>Settings</strong>
+                </div>
+
+                <div className="form__group">
+                    <Field
+                        name="use_global_settings"
+                        type="checkbox"
+                        component={renderSelectField}
+                        placeholder={t('client_global_settings')}
+                    />
+                </div>
+
+                <div className="form__group">
+                    <Field
+                        name="filtering_enabled"
+                        type="checkbox"
+                        component={renderSelectField}
+                        placeholder={t('block_domain_use_filters_and_hosts')}
+                        disabled={useGlobalSettings}
+                    />
+                </div>
+
+                <div className="form__group">
+                    <Field
+                        name="safebrowsing_enabled"
+                        type="checkbox"
+                        component={renderSelectField}
+                        placeholder={t('use_adguard_browsing_sec')}
+                        disabled={useGlobalSettings}
+                    />
+                </div>
+
+                <div className="form__group">
+                    <Field
+                        name="parental_enabled"
+                        type="checkbox"
+                        component={renderSelectField}
+                        placeholder={t('use_adguard_parental')}
+                        disabled={useGlobalSettings}
+                    />
+                </div>
+
+                <div className="form__group">
+                    <Field
+                        name="safesearch_enabled"
+                        type="checkbox"
+                        component={renderSelectField}
+                        placeholder={t('enforce_safe_search')}
+                        disabled={useGlobalSettings}
+                    />
+                </div>
+            </div>
+
+            <div className="modal-footer">
+                <div className="btn-list">
+                    <button
+                        type="button"
+                        className="btn btn-secondary btn-standard"
+                        disabled={submitting}
+                        onClick={() => {
+                            reset();
+                            toggleClientModal();
+                        }}
+                    >
+                        <Trans>cancel_btn</Trans>
+                    </button>
+                    <button
+                        type="submit"
+                        className="btn btn-success btn-standard"
+                        disabled={submitting || pristine || processingAdding || processingUpdating}
+                    >
+                        <Trans>save_btn</Trans>
+                    </button>
+                </div>
+            </div>
+        </form>
+    );
+};
+
+Form.propTypes = {
+    pristine: PropTypes.bool.isRequired,
+    handleSubmit: PropTypes.func.isRequired,
+    reset: PropTypes.func.isRequired,
+    submitting: PropTypes.bool.isRequired,
+    toggleClientModal: PropTypes.func.isRequired,
+    clientIdentifier: PropTypes.string,
+    useGlobalSettings: PropTypes.bool,
+    t: PropTypes.func.isRequired,
+    processingAdding: PropTypes.bool.isRequired,
+    processingUpdating: PropTypes.bool.isRequired,
+};
+
+const selector = formValueSelector('clientForm');
+
+Form = connect((state) => {
+    const clientIdentifier = selector(state, 'identifier');
+    const useGlobalSettings = selector(state, 'use_global_settings');
+    return {
+        clientIdentifier,
+        useGlobalSettings,
+    };
+})(Form);
+
+export default flow([
+    withNamespaces(),
+    reduxForm({
+        form: 'clientForm',
+        enableReinitialize: true,
+    }),
+])(Form);
diff --git a/client/src/components/Settings/Clients/Modal.js b/client/src/components/Settings/Clients/Modal.js
new file mode 100644
index 00000000..2c7cf7ce
--- /dev/null
+++ b/client/src/components/Settings/Clients/Modal.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Trans, withNamespaces } from 'react-i18next';
+import ReactModal from 'react-modal';
+
+import Form from './Form';
+
+const Modal = (props) => {
+    const {
+        isModalOpen,
+        currentClientData,
+        handleSubmit,
+        toggleClientModal,
+        processingAdding,
+        processingUpdating,
+    } = props;
+
+    return (
+        <ReactModal
+            className="Modal__Bootstrap modal-dialog modal-dialog-centered modal-dialog--clients"
+            closeTimeoutMS={0}
+            isOpen={isModalOpen}
+            onRequestClose={() => toggleClientModal()}
+        >
+            <div className="modal-content">
+                <div className="modal-header">
+                    <h4 className="modal-title">
+                        <Trans>client_new</Trans>
+                    </h4>
+                    <button type="button" className="close" onClick={() => toggleClientModal()}>
+                        <span className="sr-only">Close</span>
+                    </button>
+                </div>
+                <Form
+                    initialValues={{
+                        ...currentClientData,
+                    }}
+                    onSubmit={handleSubmit}
+                    toggleClientModal={toggleClientModal}
+                    processingAdding={processingAdding}
+                    processingUpdating={processingUpdating}
+                />
+            </div>
+        </ReactModal>
+    );
+};
+
+Modal.propTypes = {
+    isModalOpen: PropTypes.bool.isRequired,
+    currentClientData: PropTypes.object.isRequired,
+    handleSubmit: PropTypes.func.isRequired,
+    toggleClientModal: PropTypes.func.isRequired,
+    processingAdding: PropTypes.bool.isRequired,
+    processingUpdating: PropTypes.bool.isRequired,
+};
+
+export default withNamespaces()(Modal);
diff --git a/client/src/components/Settings/Clients/index.js b/client/src/components/Settings/Clients/index.js
new file mode 100644
index 00000000..a8ed09ab
--- /dev/null
+++ b/client/src/components/Settings/Clients/index.js
@@ -0,0 +1,252 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { Trans, withNamespaces } from 'react-i18next';
+import ReactTable from 'react-table';
+
+import { MODAL_TYPE, CLIENT_ID } from '../../../helpers/constants';
+import Card from '../../ui/Card';
+import Modal from './Modal';
+
+class Clients extends Component {
+    handleFormAdd = (values) => {
+        this.props.addClient(values);
+    };
+
+    handleFormUpdate = (values, name) => {
+        this.props.updateClient(values, name);
+    };
+
+    handleSubmit = (values) => {
+        if (this.props.modalType === MODAL_TYPE.EDIT) {
+            this.handleFormUpdate(values, this.props.modalClientName);
+        } else {
+            this.handleFormAdd(values);
+        }
+    };
+
+    cellWrap = ({ value }) => (
+        <div className="logs__row logs__row--overflow">
+            <span className="logs__text" title={value}>
+                {value}
+            </span>
+        </div>
+    );
+
+    getClient = (name, clients) => {
+        const client = clients.find(item => name === item.name);
+
+        if (client) {
+            const identifier = client.mac ? CLIENT_ID.MAC : CLIENT_ID.IP;
+
+            return {
+                identifier,
+                use_global_settings: true,
+                ...client,
+            };
+        }
+
+        return {
+            identifier: 'ip',
+            use_global_settings: true,
+        };
+    };
+
+    getStats = (ip, stats) => {
+        if (stats && stats.top_clients) {
+            return stats.top_clients[ip];
+        }
+
+        return '';
+    };
+
+    columns = [
+        {
+            Header: this.props.t('table_client'),
+            accessor: 'ip',
+            Cell: (row) => {
+                if (row.value) {
+                    return (
+                        <div className="logs__row logs__row--overflow">
+                            <span className="logs__text" title={row.value}>
+                                {row.value} <em>(IP)</em>
+                            </span>
+                        </div>
+                    );
+                } else if (row.original && row.original.mac) {
+                    return (
+                        <div className="logs__row logs__row--overflow">
+                            <span className="logs__text" title={row.original.mac}>
+                                {row.original.mac} <em>(MAC)</em>
+                            </span>
+                        </div>
+                    );
+                }
+
+                return '';
+            },
+        },
+        {
+            Header: this.props.t('table_name'),
+            accessor: 'name',
+            Cell: this.cellWrap,
+        },
+        {
+            Header: this.props.t('settings'),
+            accessor: 'use_global_settings',
+            maxWidth: 180,
+            minWidth: 150,
+            Cell: ({ value }) => {
+                const title = value ? (
+                    <Trans>settings_global</Trans>
+                ) : (
+                    <Trans>settings_custom</Trans>
+                );
+
+                return (
+                    <div className="logs__row logs__row--overflow">
+                        <div className="logs__text" title={title}>
+                            {title}
+                        </div>
+                    </div>
+                );
+            },
+        },
+        {
+            Header: this.props.t('table_statistics'),
+            accessor: 'statistics',
+            Cell: (row) => {
+                const clientIP = row.original.ip;
+                const clientStats = clientIP && this.getStats(clientIP, this.props.topStats);
+
+                if (clientStats) {
+                    return (
+                        <div className="logs__row">
+                            <div className="logs__text" title={clientStats}>
+                                {clientStats}
+                            </div>
+                        </div>
+                    );
+                }
+
+                return '–';
+            },
+        },
+        {
+            Header: this.props.t('actions_table_header'),
+            accessor: 'actions',
+            maxWidth: 220,
+            minWidth: 150,
+            Cell: (row) => {
+                const clientName = row.original.name;
+                const {
+                    toggleClientModal,
+                    deleteClient,
+                    processingDeleting,
+                    processingUpdating,
+                } = this.props;
+
+                return (
+                    <div className="logs__row logs__row--center">
+                        <button
+                            type="button"
+                            className="btn btn-outline-primary btn-sm mr-2"
+                            onClick={() =>
+                                toggleClientModal({
+                                    type: MODAL_TYPE.EDIT,
+                                    name: clientName,
+                                })
+                            }
+                            disabled={processingUpdating}
+                        >
+                            <Trans>edit_table_action</Trans>
+                        </button>
+                        <button
+                            type="button"
+                            className="btn btn-outline-secondary btn-sm"
+                            onClick={() => deleteClient({ name: clientName })}
+                            disabled={processingDeleting}
+                        >
+                            <Trans>delete_table_action</Trans>
+                        </button>
+                    </div>
+                );
+            },
+        },
+    ];
+
+    render() {
+        const {
+            t,
+            clients,
+            isModalOpen,
+            modalClientName,
+            toggleClientModal,
+            processingAdding,
+            processingUpdating,
+        } = this.props;
+
+        const currentClientData = this.getClient(modalClientName, clients);
+
+        return (
+            <Card
+                title={t('clients_title')}
+                subtitle={t('clients_desc')}
+                bodyType="card-body box-body--settings"
+            >
+                <Fragment>
+                    <ReactTable
+                        data={clients || []}
+                        columns={this.columns}
+                        noDataText={t('dhcp_leases_not_found')}
+                        className="-striped -highlight card-table-overflow"
+                        showPagination={true}
+                        defaultPageSize={10}
+                        minRows={5}
+                        resizable={false}
+                        previousText={t('previous_btn')}
+                        nextText={t('next_btn')}
+                        loadingText={t('loading_table_status')}
+                        pageText={t('page_table_footer_text')}
+                        ofText={t('of_table_footer_text')}
+                        rowsText={t('rows_table_footer_text')}
+                    />
+                    <button
+                        type="button"
+                        className="btn btn-success btn-standard mt-3"
+                        onClick={() => toggleClientModal(MODAL_TYPE.ADD)}
+                        disabled={processingAdding}
+                    >
+                        <Trans>add_client</Trans>
+                    </button>
+
+                    <Modal
+                        isModalOpen={isModalOpen}
+                        toggleClientModal={toggleClientModal}
+                        currentClientData={currentClientData}
+                        handleSubmit={this.handleSubmit}
+                        processingAdding={processingAdding}
+                        processingUpdating={processingUpdating}
+                    />
+                </Fragment>
+            </Card>
+        );
+    }
+}
+
+Clients.propTypes = {
+    t: PropTypes.func.isRequired,
+    clients: PropTypes.array.isRequired,
+    topStats: PropTypes.object.isRequired,
+    toggleClientModal: PropTypes.func.isRequired,
+    deleteClient: PropTypes.func.isRequired,
+    addClient: PropTypes.func.isRequired,
+    updateClient: PropTypes.func.isRequired,
+    isModalOpen: PropTypes.bool.isRequired,
+    modalType: PropTypes.string.isRequired,
+    modalClientName: PropTypes.string.isRequired,
+    processingAdding: PropTypes.bool.isRequired,
+    processingDeleting: PropTypes.bool.isRequired,
+    processingUpdating: PropTypes.bool.isRequired,
+};
+
+export default withNamespaces()(Clients);
diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js
index e6c39cf7..9292efb3 100644
--- a/client/src/components/Settings/index.js
+++ b/client/src/components/Settings/index.js
@@ -4,6 +4,7 @@ import { withNamespaces, Trans } from 'react-i18next';
 import Upstream from './Upstream';
 import Dhcp from './Dhcp';
 import Encryption from './Encryption';
+import Clients from './Clients';
 import Checkbox from '../ui/Checkbox';
 import Loading from '../ui/Loading';
 import PageTitle from '../ui/PageTitle';
@@ -46,29 +47,38 @@ class Settings extends Component {
             return Object.keys(settings).map((key) => {
                 const setting = settings[key];
                 const { enabled } = setting;
-                return (<Checkbox
-                    key={key}
-                    {...settings[key]}
-                    handleChange={() => this.props.toggleSetting(key, enabled)}
-                    />);
+                return (
+                    <Checkbox
+                        key={key}
+                        {...settings[key]}
+                        handleChange={() => this.props.toggleSetting(key, enabled)}
+                    />
+                );
             });
         }
         return (
-            <div><Trans>no_settings</Trans></div>
+            <div>
+                <Trans>no_settings</Trans>
+            </div>
         );
-    }
+    };
 
     render() {
-        const { settings, dashboard, t } = this.props;
+        const {
+            settings, dashboard, clients, t,
+        } = this.props;
         return (
             <Fragment>
-                <PageTitle title={ t('settings') } />
+                <PageTitle title={t('settings')} />
                 {settings.processing && <Loading />}
-                {!settings.processing &&
+                {!settings.processing && (
                     <div className="content">
                         <div className="row">
                             <div className="col-md-12">
-                                <Card title={ t('general_settings') } bodyType="card-body box-body--settings">
+                                <Card
+                                    title={t('general_settings')}
+                                    bodyType="card-body box-body--settings"
+                                >
                                     <div className="form">
                                         {this.renderSettings(settings.settingsList)}
                                     </div>
@@ -82,6 +92,22 @@ class Settings extends Component {
                                     processingTestUpstream={settings.processingTestUpstream}
                                     processingSetUpstream={settings.processingSetUpstream}
                                 />
+                                {!dashboard.processingTopStats && !dashboard.processingClients && (
+                                    <Clients
+                                        clients={dashboard.clients}
+                                        topStats={dashboard.topStats}
+                                        isModalOpen={clients.isModalOpen}
+                                        modalClientName={clients.modalClientName}
+                                        modalType={clients.modalType}
+                                        addClient={this.props.addClient}
+                                        updateClient={this.props.updateClient}
+                                        deleteClient={this.props.deleteClient}
+                                        toggleClientModal={this.props.toggleClientModal}
+                                        processingAdding={clients.processingAdding}
+                                        processingDeleting={clients.processingDeleting}
+                                        processingUpdating={clients.processingUpdating}
+                                    />
+                                )}
                                 <Encryption
                                     encryption={this.props.encryption}
                                     setTlsConfig={this.props.setTlsConfig}
@@ -97,7 +123,7 @@ class Settings extends Component {
                             </div>
                         </div>
                     </div>
-                }
+                )}
             </Fragment>
         );
     }
diff --git a/client/src/components/ui/Modal.css b/client/src/components/ui/Modal.css
index 4a000cb1..43f35f64 100644
--- a/client/src/components/ui/Modal.css
+++ b/client/src/components/ui/Modal.css
@@ -5,7 +5,7 @@
     overflow-x: hidden;
     overflow-y: auto;
     background-color: rgba(0, 0, 0, 0.5);
-    z-index: 1;
+    z-index: 105;
 }
 
 .ReactModal__Overlay--after-open {
@@ -38,3 +38,9 @@
     border: none;
     background-color: transparent;
 }
+
+@media (min-width: 576px) {
+    .modal-dialog--clients {
+        max-width: 650px;
+    }
+}
diff --git a/client/src/containers/Settings.js b/client/src/containers/Settings.js
index d593761a..95be768b 100644
--- a/client/src/containers/Settings.js
+++ b/client/src/containers/Settings.js
@@ -17,6 +17,12 @@ import {
     setTlsConfig,
     validateTlsConfig,
 } from '../actions/encryption';
+import {
+    addClient,
+    updateClient,
+    deleteClient,
+    toggleClientModal,
+} from '../actions/clients';
 import Settings from '../components/Settings';
 
 const mapStateToProps = (state) => {
@@ -25,12 +31,14 @@ const mapStateToProps = (state) => {
         dashboard,
         dhcp,
         encryption,
+        clients,
     } = state;
     const props = {
         settings,
         dashboard,
         dhcp,
         encryption,
+        clients,
     };
     return props;
 };
@@ -50,6 +58,10 @@ const mapDispatchToProps = {
     getTlsStatus,
     setTlsConfig,
     validateTlsConfig,
+    addClient,
+    updateClient,
+    deleteClient,
+    toggleClientModal,
 };
 
 export default connect(
diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js
index 503ca2ed..d5f94f8c 100644
--- a/client/src/helpers/constants.js
+++ b/client/src/helpers/constants.js
@@ -1,5 +1,6 @@
 export const R_URL_REQUIRES_PROTOCOL = /^https?:\/\/\w[\w_\-.]*\.[a-z]{2,8}[^\s]*$/;
 export const R_IPV4 = /^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/g;
+export const R_MAC = /^((([a-fA-F0-9][a-fA-F0-9]+[-]){5}|([a-fA-F0-9][a-fA-F0-9]+[:]){5})([a-fA-F0-9][a-fA-F0-9])$)|(^([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]+[.]){2}([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]))$/g;
 
 export const STATS_NAMES = {
     avg_processing_time: 'average_processing_time',
@@ -19,7 +20,8 @@ export const STATUS_COLORS = {
 
 export const REPOSITORY = {
     URL: 'https://github.com/AdguardTeam/AdGuardHome',
-    TRACKERS_DB: 'https://github.com/AdguardTeam/AdGuardHome/tree/master/client/src/helpers/trackers/adguard.json',
+    TRACKERS_DB:
+        'https://github.com/AdguardTeam/AdGuardHome/tree/master/client/src/helpers/trackers/adguard.json',
 };
 
 export const PRIVACY_POLICY_LINK = 'https://adguard.com/privacy/home.html';
@@ -165,3 +167,13 @@ export const DHCP_STATUS_RESPONSE = {
     NO: 'no',
     ERROR: 'error',
 };
+
+export const MODAL_TYPE = {
+    ADD: 'add',
+    EDIT: 'edit',
+};
+
+export const CLIENT_ID = {
+    MAC: 'mac',
+    IP: 'ip',
+};
diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js
index c9703033..72397396 100644
--- a/client/src/helpers/form.js
+++ b/client/src/helpers/form.js
@@ -1,7 +1,7 @@
 import React, { Fragment } from 'react';
 import { Trans } from 'react-i18next';
 
-import { R_IPV4, UNSAFE_PORTS } from '../helpers/constants';
+import { R_IPV4, R_MAC, UNSAFE_PORTS } from '../helpers/constants';
 
 export const renderField = ({
     input, id, className, placeholder, type, disabled, meta: { touched, error },
@@ -55,6 +55,13 @@ export const ipv4 = (value) => {
     return false;
 };
 
+export const mac = (value) => {
+    if (value && !new RegExp(R_MAC).test(value)) {
+        return <Trans>form_error_mac_format</Trans>;
+    }
+    return false;
+};
+
 export const isPositive = (value) => {
     if ((value || value === 0) && (value <= 0)) {
         return <Trans>form_error_positive</Trans>;
diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js
index d3128f5d..773b54f9 100644
--- a/client/src/helpers/helpers.js
+++ b/client/src/helpers/helpers.js
@@ -208,3 +208,21 @@ export const getClientName = (clients, ip) => {
     const client = clients.find(item => ip === item.ip);
     return (client && client.name) || '';
 };
+
+export const sortClients = (clients) => {
+    const compare = (a, b) => {
+        const nameA = a.name.toUpperCase();
+        const nameB = b.name.toUpperCase();
+        let comparison = 0;
+
+        if (nameA > nameB) {
+            comparison = 1;
+        } else if (nameA < nameB) {
+            comparison = -1;
+        }
+
+        return comparison;
+    };
+
+    return clients.sort(compare);
+};
diff --git a/client/src/reducers/clients.js b/client/src/reducers/clients.js
new file mode 100644
index 00000000..0946e437
--- /dev/null
+++ b/client/src/reducers/clients.js
@@ -0,0 +1,63 @@
+import { handleActions } from 'redux-actions';
+
+import * as actions from '../actions/clients';
+
+const clients = handleActions({
+    [actions.addClientRequest]: state => ({ ...state, processingAdding: true }),
+    [actions.addClientFailure]: state => ({ ...state, processingAdding: false }),
+    [actions.addClientSuccess]: (state) => {
+        const newState = {
+            ...state,
+            processingAdding: false,
+        };
+        return newState;
+    },
+
+    [actions.deleteClientRequest]: state => ({ ...state, processingDeleting: true }),
+    [actions.deleteClientFailure]: state => ({ ...state, processingDeleting: false }),
+    [actions.deleteClientSuccess]: (state) => {
+        const newState = {
+            ...state,
+            processingDeleting: false,
+        };
+        return newState;
+    },
+
+    [actions.updateClientRequest]: state => ({ ...state, processingUpdating: true }),
+    [actions.updateClientFailure]: state => ({ ...state, processingUpdating: false }),
+    [actions.updateClientSuccess]: (state) => {
+        const newState = {
+            ...state,
+            processingUpdating: false,
+        };
+        return newState;
+    },
+
+    [actions.toggleClientModal]: (state, { payload }) => {
+        if (payload) {
+            const newState = {
+                ...state,
+                modalType: payload.type || '',
+                modalClientName: payload.name || '',
+                isModalOpen: !state.isModalOpen,
+            };
+            return newState;
+        }
+
+        const newState = {
+            ...state,
+            isModalOpen: !state.isModalOpen,
+        };
+        return newState;
+    },
+}, {
+    processing: true,
+    processingAdding: false,
+    processingDeleting: false,
+    processingUpdating: false,
+    isModalOpen: false,
+    modalClientName: '',
+    modalType: '',
+});
+
+export default clients;
diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js
index 09c93a99..206a0de4 100644
--- a/client/src/reducers/index.js
+++ b/client/src/reducers/index.js
@@ -7,6 +7,7 @@ import versionCompare from '../helpers/versionCompare';
 import * as actions from '../actions';
 import toasts from './toasts';
 import encryption from './encryption';
+import clients from './clients';
 
 const settings = handleActions({
     [actions.initSettingsRequest]: state => ({ ...state, processing: true }),
@@ -209,6 +210,7 @@ const dashboard = handleActions({
     dnsAddresses: [],
     dnsVersion: '',
     clients: [],
+    topStats: [],
 });
 
 const queryLogs = handleActions({
@@ -361,6 +363,7 @@ export default combineReducers({
     toasts,
     dhcp,
     encryption,
+    clients,
     loadingBar: loadingBarReducer,
     form: formReducer,
 });