From 425fac8e9cc9ecf6abdd05db1c3ba66f69329fe7 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 25 Nov 2022 15:55:26 +0100 Subject: [PATCH] DNS (#101) Added DNS tab for managing Nameservers. Users will be able to add multiple nameservers and set distribution groups that dictate to which peers the settings will be applied. With this PR we also got a set of group handlers that can be reused. --- package-lock.json | 1 + package.json | 1 + src/App.tsx | 82 +-- src/components/AccessControlNew.tsx | 2 +- src/components/NameServerGroupUpdate.tsx | 616 +++++++++++++++++++++++ src/components/Navbar.tsx | 20 +- src/components/PeerUpdate.tsx | 82 ++- src/store/index.ts | 2 + src/store/nameservers/actions.ts | 35 ++ src/store/nameservers/index.ts | 7 + src/store/nameservers/reducer.ts | 95 ++++ src/store/nameservers/sagas.ts | 156 ++++++ src/store/nameservers/service.ts | 32 ++ src/store/nameservers/types.ts | 21 + src/store/root-action.ts | 4 +- src/store/root-reducer.ts | 4 +- src/utils/groups.tsx | 136 +++++ src/views/AccessControl.tsx | 6 +- src/views/DNS.tsx | 445 ++++++++++++++++ 19 files changed, 1671 insertions(+), 76 deletions(-) create mode 100644 src/components/NameServerGroupUpdate.tsx create mode 100644 src/store/nameservers/actions.ts create mode 100644 src/store/nameservers/index.ts create mode 100644 src/store/nameservers/reducer.ts create mode 100644 src/store/nameservers/sagas.ts create mode 100644 src/store/nameservers/service.ts create mode 100644 src/store/nameservers/types.ts create mode 100644 src/utils/groups.tsx create mode 100644 src/views/DNS.tsx diff --git a/package-lock.json b/package-lock.json index 9d7f8af..7868624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "lodash": "^4.17.21", "postcss": "^8.4.12", "prop-types": "^15.7.2", + "punycode": "^2.1.1", "rc-overflow": "^1.2.8", "react": "^18.2.0", "react-dom": "^18.1.0", diff --git a/package.json b/package.json index deb41f6..eefb17d 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lodash": "^4.17.21", "postcss": "^8.4.12", "prop-types": "^15.7.2", + "punycode": "^2.1.1", "rc-overflow": "^1.2.8", "react": "^18.2.0", "react-dom": "^18.1.0", diff --git a/src/App.tsx b/src/App.tsx index efeb86e..10be792 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,9 @@ import FooterComponent from "./components/FooterComponent"; import {useGetAccessTokenSilently} from "./utils/token"; import {User} from "./store/user/types"; import {SecureLoading} from "./components/Loading"; +import DNS from "./views/DNS"; + + const {Header, Content} = Layout; @@ -52,8 +55,8 @@ function App() { if (!run.current) { run.current = true apiClient.request('GET', `/api/users`, {getAccessTokenSilently: getAccessTokenSilently}) - .then((resp) => { - setShow(true) + .then(() => { + setShow(true) }) .catch(e => { setShow(true) @@ -68,43 +71,44 @@ function App() { {!show && } {show && - - -
- - - - - - - -
- - - { - return ( - - ) - }} - /> - - - - - - - - - -
+ + +
+ + + + + + + +
+ + + { + return ( + + ) + }} + /> + + + + + + + + + + +
}
diff --git a/src/components/AccessControlNew.tsx b/src/components/AccessControlNew.tsx index 60729e9..6a3ba93 100644 --- a/src/components/AccessControlNew.tsx +++ b/src/components/AccessControlNew.tsx @@ -228,7 +228,7 @@ const AccessControlNew = () => { const selectValidator = (_: RuleObject, value: string[]) => { let hasSpaceNamed = [] if (!value.length) { - return Promise.reject(new Error("Please enter ate least one group")) + return Promise.reject(new Error("Please enter at least one group")) } value.forEach(function (v: string) { diff --git a/src/components/NameServerGroupUpdate.tsx b/src/components/NameServerGroupUpdate.tsx new file mode 100644 index 0000000..03c5444 --- /dev/null +++ b/src/components/NameServerGroupUpdate.tsx @@ -0,0 +1,616 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "typesafe-actions"; +import {actions as nsGroupActions} from '../store/nameservers'; +import { + Button, + Col, + Divider, + Drawer, + Form, + FormListFieldData, + Input, + InputNumber, + Radio, + Row, + Select, + Space, + Tooltip, + Typography +} from "antd"; +import {CloseOutlined, FlagFilled, MinusCircleOutlined, PlusOutlined, QuestionCircleFilled, QuestionCircleOutlined} from "@ant-design/icons"; +import {Header} from "antd/es/layout/layout"; +import {RuleObject} from "antd/lib/form"; +import cidrRegex from 'cidr-regex'; +import {NameServer, NameServerGroup, NameServerGroupToSave} from "../store/nameservers/types"; +import {useGetGroupTagHelpers} from "../utils/groups" +import {useGetAccessTokenSilently} from "../utils/token"; + +const {Paragraph} = Typography; + +interface formNSGroup extends NameServerGroup { +} + +const NameServerGroupUpdate = () => { + const { + tagRender, + handleChangeTags, + dropDownRender, + optionRender, + tagGroups, + getExistingAndToCreateGroupsLists, + getGroupNamesFromIDs, + selectValidator + } = useGetGroupTagHelpers() + const dispatch = useDispatch() + const {getAccessTokenSilently} = useGetAccessTokenSilently() + const {Option} = Select; + const nsGroup = useSelector((state: RootState) => state.nameserverGroup.nameserverGroup) + const setupNewNameServerGroupVisible = useSelector((state: RootState) => state.nameserverGroup.setupNewNameServerGroupVisible) + const savedNSGroup = useSelector((state: RootState) => state.nameserverGroup.savedNameServerGroup) + const nsGroupData = useSelector((state: RootState) => state.nameserverGroup.data); + + const [formNSGroup, setFormNSGroup] = useState({} as formNSGroup) + const [form] = Form.useForm() + const [editName, setEditName] = useState(false) + const [isPrimary, setIsPrimary] = useState(false) + const [editDescription, setEditDescription] = useState(false) + const inputNameRef = useRef(null) + const inputDescriptionRef = useRef(null) + const [selectCustom, setSelectCustom] = useState(false) + + const optionsDisabledEnabled = [{label: 'Enabled', value: true}, {label: 'Disabled', value: false}] + const optionsPrimary = [{label: 'Yes', value: true}, {label: 'No', value: false}] + + useEffect(() => { + if (!nsGroup) return + + let newFormGroup = { + ...nsGroup, + groups: getGroupNamesFromIDs(nsGroup.groups), + } as formNSGroup + setFormNSGroup(newFormGroup) + form.setFieldsValue(newFormGroup) + if (nsGroup.id) { + setSelectCustom(true) + } + if (nsGroup.primary !== undefined) { + setIsPrimary(nsGroup.primary) + } + }, [nsGroup]) + + const onCancel = () => { + dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(false)); + dispatch(nsGroupActions.setNameServerGroup( + { + id: '', + name: '', + description: '', + primary: false, + domains: [], + nameservers: [] as NameServer[], + groups: [], + enabled: false, + } as NameServerGroup + )) + setEditName(false) + setSelectCustom(false) + setIsPrimary(false) + } + + const onChange = (changedValues:any) => { + if (changedValues.primary !== undefined) { + setIsPrimary(changedValues.primary) + } + } + + let googleChoice = 'Google DNS' + let cloudflareChoice = 'Cloudflare DNS' + let quad9Choice = 'Quad9 DNS' + let customChoice = 'Add custom nameserver' + + let defaultDNSOptions: NameServerGroup[] = [ + { + name: googleChoice, + description: 'Google DNS servers', + domains: [], + primary: true, + nameservers: [ + { + ip: "8.8.8.8", + ns_type: "udp", + port: 53, + }, + { + ip: "8.8.4.4", + ns_type: "udp", + port: 53, + }, + ], + groups: [], + enabled: true, + }, + { + name: cloudflareChoice, + description: 'Cloudflare DNS servers', + domains: [], + primary: true, + nameservers: [ + { + ip: "1.1.1.1", + ns_type: "udp", + port: 53, + }, + { + ip: "1.0.0.1", + ns_type: "udp", + port: 53, + }, + ], + groups: [], + enabled: true, + }, + { + name: quad9Choice, + description: 'Quad9 DNS servers', + domains: [], + primary: true, + nameservers: [ + { + ip: "9.9.9.9", + ns_type: "udp", + port: 53, + }, + { + ip: "149.112.112.112", + ns_type: "udp", + port: 53, + }, + ], + groups: [], + enabled: true, + }, + ] + + const handleSelectChange = (value: string) => { + console.log(`selected ${value}`); + let nsGroupLocal = {} as NameServerGroup + if (value === customChoice) { + nsGroupLocal = nsGroup + } else { + defaultDNSOptions.forEach((nsg) => { + if (value === nsg.name) { + nsGroupLocal = nsg + } + }) + } + let newFormGroup = { + ...nsGroupLocal, + groups: getGroupNamesFromIDs(nsGroupLocal.groups), + } as formNSGroup + setFormNSGroup(newFormGroup) + form.setFieldsValue(newFormGroup) + setSelectCustom(true) + }; + + const handleFormSubmit = () => { + + form.validateFields() + .then((values) => { + const nsGroupToSave = createNSGroupToSave(values as NameServerGroup) + dispatch(nsGroupActions.saveNameServerGroup.request({ + getAccessTokenSilently: getAccessTokenSilently, + payload: nsGroupToSave + })) + + }) + .then(() => onCancel()) + .catch((errorInfo) => { + console.log('errorInfo', errorInfo) + }); + } + + const createNSGroupToSave = (values:NameServerGroup): NameServerGroupToSave => { + let [existingGroups, newGroups] = getExistingAndToCreateGroupsLists(values.groups) + return { + id: formNSGroup.id || null, + name: values.name ? values.name : formNSGroup.name, + description: values.description ? values.description : formNSGroup.description, + primary: values.primary, + domains: values.primary ? [] : values.domains, + nameservers: values.nameservers, + groups: existingGroups, + groupsToCreate: newGroups, + enabled: values.enabled, + } as NameServerGroupToSave + } + + const toggleEditName = (b: boolean) => { + setEditDescription(b) + } + + const toggleEditDescription = (b: boolean) => { + setEditDescription(b) + } + + const domainRegex = /(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/; + + const domainValidator = (_: RuleObject, domain: string) => { + if (domainRegex.test(domain)) { + return Promise.resolve() + } + return Promise.reject(new Error("Please enter a valid domain, e.g. example.com or intra.example.com")) + } + + const nameValidator = (_: RuleObject, value: string) => { + const found = nsGroupData.find(u => u.name == value && u.id !== formNSGroup.id) + if (found) { + return Promise.reject(new Error("Please enter a unique name for your nameserver configuration")) + } + + return Promise.resolve() + } + + const ipValidator = (_: RuleObject, value: string) => { + if (!cidrRegex().test(value + "/32")) { + return Promise.reject(new Error("Please enter a valid IP, e.g. 192.168.1.1 or 8.8.8.8")) + } + + return Promise.resolve() + } + + // @ts-ignore + const formListValidator = (_: RuleObject, names) => { + if (names.length >= 3) { + return Promise.reject(new Error("Exceeded maximum number of Nameservers. (Max is 2)")); + } + if (names.length < 1) { + return Promise.reject(new Error("You should add at least 1 Nameserver")); + } + return Promise.resolve() + } + + // @ts-ignore + const renderNSList = (fields: FormListFieldData[], {add, remove}, {errors}) => ( + <> + Nameservers + {!!fields.length && ( + + + + Protocol + + + Nameserver IP + + + Port + + + + )} + {fields.map((field, index) => { + return ( + + + + + + + + + + + + + + + + + + remove(field.name)}/> + + + ) + })} + + + + + + + ) + + // @ts-ignore + const renderDomains = (fields: FormListFieldData[], {add, remove}, {errors}) => ( + <> + + + + Match domains + + + + + + + + + {fields.map((field, index) => { + return ( + + + + + + + ) + })} + + + + + + + ) + + return ( + <> + {nsGroup && + + + + + } + > + {selectCustom ? + (
+ + +
+ + + {!editName && !editDescription && formNSGroup.id && + + } + + + {!editName && formNSGroup.id ? ( +
toggleEditName(true)}>{formNSGroup.id ? formNSGroup.name : 'New nameserver group'}
+ ) : ( + + toggleEditName(false)} + onBlur={() => toggleEditName(false)} autoComplete="off" + maxLength={40}/> + + )} + {!editDescription ? ( +
toggleEditDescription(true)}>{formNSGroup.description && formNSGroup.description.trim() !== "" ? formNSGroup.description : 'Add description...'}
+ ) : ( + + toggleEditDescription(false)} + onBlur={() => toggleEditDescription(false)} + autoComplete="off" maxLength={200}/> + + )} + + +
+ + + + + + +
+ + + + + + + + + + + + + {renderNSList} + + + + + + + + + + {renderDomains} + + + + + + + + + + + + + + + + + Nameservers let you define resolvers for your DNS queries. + Because not all operating systems support match-only domain resolution, + you should define at least one set of nameservers to resolve all domains per distribution group. + + + + + + + + +
+
) : + ( + + + + Select a predefined nameserver + + + + + toggleEditName(false)} - onBlur={() => toggleEditName(false)} - autoComplete="off"/> - )} + + + + toggleEditName(false)} + onBlur={() => toggleEditName(false)} + autoComplete="off" + max={59}/> + + + + + {estimatedName} + + + + + + + + )} @@ -370,6 +406,20 @@ const PeerUpdate = () => { + + + + + + + {formPeer.user_id && ( diff --git a/src/store/index.ts b/src/store/index.ts index 1f71a2b..43cd043 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -8,6 +8,7 @@ import { sagas as userSagas } from './user'; import { sagas as ruleSagas } from './rule'; import { sagas as groupSagas } from './group'; import { sagas as routeSagas } from './route'; +import { sagas as nameserverGroupSagas } from './nameservers'; import rootReducer from './root-reducer'; import { apiClient } from '../services/api-client'; @@ -25,5 +26,6 @@ sagaMiddleware.run(userSagas); sagaMiddleware.run(ruleSagas); sagaMiddleware.run(groupSagas); sagaMiddleware.run(routeSagas); +sagaMiddleware.run(nameserverGroupSagas); export { apiClient, rootReducer, store }; \ No newline at end of file diff --git a/src/store/nameservers/actions.ts b/src/store/nameservers/actions.ts new file mode 100644 index 0000000..4c86aa7 --- /dev/null +++ b/src/store/nameservers/actions.ts @@ -0,0 +1,35 @@ +import { ActionType, createAction, createAsyncAction } from 'typesafe-actions'; +import {NameServerGroup, NameServerGroupToSave} from './types'; +import {ApiError, CreateResponse, DeleteResponse, RequestPayload} from '../../services/api-client/types'; + +const actions = { + getNameServerGroups: createAsyncAction( + 'GET_NameServerGroup_REQUEST', + 'GET_NameServerGroup_SUCCESS', + 'GET_NameServerGroup_FAILURE', + ), NameServerGroup[], ApiError>(), + + saveNameServerGroup: createAsyncAction( + 'SAVE_NameServerGroup_REQUEST', + 'SAVE_NameServerGroup_SUCCESS', + 'SAVE_NameServerGroup_FAILURE', + ), CreateResponse, CreateResponse>(), + setSavedNameServerGroup: createAction('SET_CREATE_NameServerGroup')>(), + resetSavedNameServerGroup: createAction('RESET_CREATE_NameServerGroup')(), + + deleteNameServerGroup: createAsyncAction( + 'DELETE_NameServerGroup_REQUEST', + 'DELETE_NameServerGroup_SUCCESS', + 'DELETE_NameServerGroup_FAILURE' + ), DeleteResponse, DeleteResponse>(), + setDeletedNameServerGroup: createAction('SET_DELETED_NameServerGroup')>(), + resetDeletedNameServerGroup: createAction('RESET_DELETED_NameServerGroup')(), + removeNameServerGroup: createAction('REMOVE_NameServerGroup')(), + + setNameServerGroup: createAction('SET_NameServerGroup')(), + setSetupNewNameServerGroupVisible: createAction('SET_SETUP_NEW_NameServerGroup_VISIBLE')(), + setSetupNewNameServerGroupHA: createAction('SET_SETUP_NEW_NameServerGroup_HA')() +}; + +export type ActionTypes = ActionType; +export default actions; diff --git a/src/store/nameservers/index.ts b/src/store/nameservers/index.ts new file mode 100644 index 0000000..1f10d21 --- /dev/null +++ b/src/store/nameservers/index.ts @@ -0,0 +1,7 @@ +import actions, { ActionTypes as _actionTypes } from './actions'; +import reducer from './reducer'; +import sagas from './sagas'; + +export type ActionTypes = _actionTypes; + +export { actions, reducer, sagas }; diff --git a/src/store/nameservers/reducer.ts b/src/store/nameservers/reducer.ts new file mode 100644 index 0000000..f63fcfc --- /dev/null +++ b/src/store/nameservers/reducer.ts @@ -0,0 +1,95 @@ +import { createReducer } from 'typesafe-actions'; +import { combineReducers } from 'redux'; +import { NameServerGroup } from './types'; +import actions, { ActionTypes } from './actions'; +import {ApiError, DeleteResponse, CreateResponse} from "../../services/api-client/types"; + +type StateType = Readonly<{ + data: NameServerGroup[] | null; + nameserverGroup: NameServerGroup | null; + loading: boolean; + failed: ApiError | null; + saving: boolean; + deleteNameServerGroup: DeleteResponse; + savedNameServerGroup: CreateResponse; + setupNewNameServerGroupVisible: boolean; + setupNewNameServerGroupHA: boolean +}>; + +const initialState: StateType = { + data: [], + nameserverGroup: null, + loading: false, + failed: null, + saving: false, + deleteNameServerGroup: >{ + loading: false, + success: false, + failure: false, + error: null, + data : null + }, + savedNameServerGroup: >{ + loading: false, + success: false, + failure: false, + error: null, + data : null + }, + setupNewNameServerGroupVisible: false, + setupNewNameServerGroupHA: false +}; + +const data = createReducer(initialState.data as NameServerGroup[]) + .handleAction(actions.getNameServerGroups.success,(_, action) => action.payload) + .handleAction(actions.getNameServerGroups.failure, () => []); + +const nameserverGroup = createReducer(initialState.nameserverGroup as NameServerGroup) + .handleAction(actions.setNameServerGroup, (store, action) => action.payload); + +const loading = createReducer(initialState.loading) + .handleAction(actions.getNameServerGroups.request, () => true) + .handleAction(actions.getNameServerGroups.success, () => false) + .handleAction(actions.getNameServerGroups.failure, () => false); + +const failed = createReducer(initialState.failed) + .handleAction(actions.getNameServerGroups.request, () => null) + .handleAction(actions.getNameServerGroups.success, () => null) + .handleAction(actions.getNameServerGroups.failure, (store, action) => action.payload); + +const saving = createReducer(initialState.saving) + .handleAction(actions.getNameServerGroups.request, () => true) + .handleAction(actions.getNameServerGroups.success, () => false) + .handleAction(actions.getNameServerGroups.failure, () => false); + +const deletedNameServerGroup = createReducer, ActionTypes>(initialState.deleteNameServerGroup) + .handleAction(actions.deleteNameServerGroup.request, () => initialState.deleteNameServerGroup) + .handleAction(actions.deleteNameServerGroup.success, (store, action) => action.payload) + .handleAction(actions.deleteNameServerGroup.failure, (store, action) => action.payload) + .handleAction(actions.setDeletedNameServerGroup, (store, action) => action.payload) + .handleAction(actions.resetDeletedNameServerGroup, () => initialState.deleteNameServerGroup) + +const savedNameServerGroup = createReducer, ActionTypes>(initialState.savedNameServerGroup) + .handleAction(actions.saveNameServerGroup.request, () => initialState.savedNameServerGroup) + .handleAction(actions.saveNameServerGroup.success, (store, action) => action.payload) + .handleAction(actions.saveNameServerGroup.failure, (store, action) => action.payload) + .handleAction(actions.setSavedNameServerGroup, (store, action) => action.payload) + .handleAction(actions.resetSavedNameServerGroup, () => initialState.savedNameServerGroup) + +const setupNewNameServerGroupVisible = createReducer(initialState.setupNewNameServerGroupVisible) + .handleAction(actions.setSetupNewNameServerGroupVisible, (store, action) => action.payload) + +const setupNewNameServerGroupHA = createReducer(initialState.setupNewNameServerGroupHA) + .handleAction(actions.setSetupNewNameServerGroupHA, (store, action) => action.payload) + +export default combineReducers({ + data, + nameserverGroup, + loading, + failed, + saving, + deletedNameServerGroup, + savedNameServerGroup, + setupNewNameServerGroupVisible, + setupNewNameServerGroupHA +}); diff --git a/src/store/nameservers/sagas.ts b/src/store/nameservers/sagas.ts new file mode 100644 index 0000000..747a16c --- /dev/null +++ b/src/store/nameservers/sagas.ts @@ -0,0 +1,156 @@ +import {all, call, put, select, takeLatest} from 'redux-saga/effects'; +import {ApiError, ApiResponse, CreateResponse, DeleteResponse} from '../../services/api-client/types'; +import {NameServerGroup} from './types' +import service from './service'; +import actions from './actions'; +import serviceGroup from "../group/service"; +import {Group} from "../group/types"; +import {actions as groupActions} from "../group"; + +export function* getNameServerGroups(action: ReturnType): Generator { + try { + + yield put(actions.setDeletedNameServerGroup({ + loading: false, + success: false, + failure: false, + error: null, + data: null + } as DeleteResponse)) + + const effect = yield call(service.getNameServerGroups, action.payload); + const response = effect as ApiResponse; + + yield put(actions.getNameServerGroups.success(response.body)); + } catch (err) { + yield put(actions.getNameServerGroups.failure(err as ApiError)); + } +} + +export function* setCreatedNameServerGroup(action: ReturnType): Generator { + yield put(actions.setSavedNameServerGroup(action.payload)) +} + +export function* saveNameServerGroup(action: ReturnType): Generator { + try { + yield put(actions.setSavedNameServerGroup({ + loading: true, + success: false, + failure: false, + error: null, + data: null + } as CreateResponse)) + + const nameserverGroupToSave = action.payload.payload + + let groupsToCreate = nameserverGroupToSave.groupsToCreate + if (!groupsToCreate) { + groupsToCreate = [] + } + + // first, create groups that were newly added by user + const responsesGroup = yield all(groupsToCreate.map(g => call(serviceGroup.createGroup, { + getAccessTokenSilently: action.payload.getAccessTokenSilently, + payload: {name: g} + }) + )) + + const resGroups = (responsesGroup as ApiResponse[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id) + const newGroups = [...nameserverGroupToSave.groups, ...resGroups] + + const payloadToSave = { + getAccessTokenSilently: action.payload.getAccessTokenSilently, + payload: { + id: nameserverGroupToSave.id, + name: nameserverGroupToSave.name, + description: nameserverGroupToSave.description, + primary: nameserverGroupToSave.primary, + domains: nameserverGroupToSave.domains, + nameservers: nameserverGroupToSave.nameservers, + groups: newGroups, + enabled: nameserverGroupToSave.enabled, + } as NameServerGroup + } + + let effect + if (!nameserverGroupToSave.id) { + effect = yield call(service.createNameServerGroup, payloadToSave); + } else { + payloadToSave.payload.id = nameserverGroupToSave.id + effect = yield call(service.editNameServerGroup, payloadToSave); + } + + const response = effect as ApiResponse; + + yield put(actions.saveNameServerGroup.success({ + loading: false, + success: true, + failure: false, + error: null, + data: response.body + } as CreateResponse)); + + yield put(groupActions.getGroups.request({ + getAccessTokenSilently: action.payload.getAccessTokenSilently, + payload: null + })); + + yield put(actions.getNameServerGroups.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: null })); + + } catch (err) { + yield put(actions.saveNameServerGroup.failure({ + loading: false, + success: false, + failure: true, + error: err as ApiError, + data: null + } as CreateResponse)); + } +} + +export function* setDeleteNameServerGroup(action: ReturnType): Generator { + yield put(actions.setDeletedNameServerGroup(action.payload)) +} + +export function* deleteNameServerGroup(action: ReturnType): Generator { + try { + yield call(actions.setDeletedNameServerGroup,{ + loading: true, + success: false, + failure: false, + error: null, + data: null + } as DeleteResponse) + + const effect = yield call(service.deletedNameServerGroup, action.payload); + const response = effect as ApiResponse; + + yield put(actions.deleteNameServerGroup.success({ + loading: false, + success: true, + failure: false, + error: null, + data: response.body + } as DeleteResponse)); + + const nameserverGroup = (yield select(state => state.nameserverGroup.data)) as NameServerGroup[] + yield put(actions.getNameServerGroups.success(nameserverGroup.filter((p:NameServerGroup) => p.id !== action.payload.payload))) + } catch (err) { + yield put(actions.deleteNameServerGroup.failure({ + loading: false, + success: false, + failure: false, + error: err as ApiError, + data: null + } as DeleteResponse)); + } +} + +export default function* sagas(): Generator { + yield all([ + takeLatest(actions.getNameServerGroups.request, getNameServerGroups), + takeLatest(actions.saveNameServerGroup.request, saveNameServerGroup), + takeLatest(actions.deleteNameServerGroup.request, deleteNameServerGroup) + ]); +} + diff --git a/src/store/nameservers/service.ts b/src/store/nameservers/service.ts new file mode 100644 index 0000000..aa1cc79 --- /dev/null +++ b/src/store/nameservers/service.ts @@ -0,0 +1,32 @@ +import {ApiResponse, RequestPayload} from '../../services/api-client/types'; +import { apiClient } from '../../services/api-client'; +import { NameServerGroup } from './types'; + +export default { + async getNameServerGroups(payload:RequestPayload): Promise> { + return apiClient.get( + `/api/dns/nameservers`, + payload + ); + }, + async deletedNameServerGroup(payload:RequestPayload): Promise> { + return apiClient.delete( + `/api/dns/nameservers/` + payload.payload, + payload + ); + }, + async createNameServerGroup(payload:RequestPayload): Promise> { + return apiClient.post( + `/api/dns/nameservers`, + payload + ); + }, + async editNameServerGroup(payload:RequestPayload): Promise> { + const id = payload.payload.id + delete payload.payload.id + return apiClient.put( + `/api/dns/nameservers/${id}`, + payload + ); + }, +}; diff --git a/src/store/nameservers/types.ts b/src/store/nameservers/types.ts new file mode 100644 index 0000000..09620f9 --- /dev/null +++ b/src/store/nameservers/types.ts @@ -0,0 +1,21 @@ +export interface NameServerGroup { + id?: string + name: string + description: string + primary: boolean + domains: string[] + nameservers: NameServer[] + groups: string[] + enabled: boolean +} + +export interface NameServer { + ip: string + ns_type: string + port: number +} + +export interface NameServerGroupToSave extends NameServerGroup +{ + groupsToCreate: string[] +} \ No newline at end of file diff --git a/src/store/root-action.ts b/src/store/root-action.ts index 51b4b76..5039686 100644 --- a/src/store/root-action.ts +++ b/src/store/root-action.ts @@ -4,6 +4,7 @@ import { actions as UserActions } from './user'; import { actions as GroupActions } from './group'; import { actions as RuleActions } from './rule'; import { actions as RouteActions } from './route'; +import { actions as NameServerGroupActions } from './nameservers'; export default { peer: PeerActions, @@ -11,5 +12,6 @@ export default { user: UserActions, group: GroupActions, rule: RuleActions, - route: RouteActions + route: RouteActions, + nameserverGroup: NameServerGroupActions }; diff --git a/src/store/root-reducer.ts b/src/store/root-reducer.ts index b1a520a..2751d85 100644 --- a/src/store/root-reducer.ts +++ b/src/store/root-reducer.ts @@ -6,6 +6,7 @@ import { reducer as user } from './user'; import { reducer as group } from './group'; import { reducer as rule } from './rule'; import { reducer as route } from './route'; +import { reducer as nameserverGroup } from './nameservers'; export default combineReducers({ peer, @@ -13,5 +14,6 @@ export default combineReducers({ user, group, rule, - route + route, + nameserverGroup }); diff --git a/src/utils/groups.tsx b/src/utils/groups.tsx new file mode 100644 index 0000000..a4dcbfd --- /dev/null +++ b/src/utils/groups.tsx @@ -0,0 +1,136 @@ +import {CustomTagProps} from "rc-select/lib/BaseSelect"; +import React, {useEffect, useState} from "react"; +import {Col, Divider, Row, Tag} from "antd"; +import {useSelector} from "react-redux"; +import {RootState} from "typesafe-actions"; +import {RuleObject} from "antd/lib/form"; + +export const useGetGroupTagHelpers = () => { + const groups = useSelector((state: RootState) => state.group.data) + + const [tagGroups, setTagGroups] = useState([] as string[]) + const [groupTagFilterAll, setGroupTagFilterAll] = useState(false) + const [selectedTagGroups, setSelectedTagGroups] = useState([] as string[]) + + const tagRender = (props: CustomTagProps) => { + const {value, closable, onClose} = props; + const onPreventMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + return ( + + {value} + + ); + } + + const handleChangeTags = (value: string[]) => { + let validatedValues: string[] = [] + value.forEach(function (v) { + if (v.trim().length) { + validatedValues.push(v) + } + }) + setSelectedTagGroups(validatedValues) + }; + + const dropDownRender = (menu: React.ReactElement) => ( + <> + {menu} + + + + Add new group by pressing "Enter" + + + + + + + + + ) + + const optionRender = (label: string) => { + let peersCount = '' + const g = groups.find(_g => _g.name === label) + if (g) peersCount = ` - ${g.peers_count || 0} ${(!g.peers_count || parseInt(g.peers_count) !== 1) ? 'peers' : 'peer'} ` + return ( + <> + + {label} + + {peersCount} + + ) + } + + const getExistingAndToCreateGroupsLists = (groupNameList: string[]): [string[], string[]] => { + const groupIDList = groups?.filter(g => groupNameList.includes(g.name)).map(g => g.id || '') || [] + // find groups that do not yet exist (newly added by the user) + const existingGroupsNames: string[] = groups?.map(g => g.name); + const groupNameListToCreate = groupNameList.filter(s => !existingGroupsNames.includes(s)) + return [groupIDList, groupNameListToCreate] + } + + const getGroupNamesFromIDs = (groupIDList: string[]): string[] => { + if (!groupIDList) { + return [] + } + + return groups?.filter(g => groupIDList.includes(g.id!)).map(g => g.name || '') || [] + } + + const selectValidator = (_: RuleObject, value: string[]) => { + let hasSpaceNamed = [] + if (!value.length) { + return Promise.reject(new Error("Please enter at least one group")) + } + + value.forEach(function (v: string) { + if (!v.trim().length) { + hasSpaceNamed.push(v) + } + }) + + if (hasSpaceNamed.length) { + return Promise.reject(new Error("Group names with just spaces are not allowed")) + } + + return Promise.resolve() + } + + useEffect(() => { + if (groupTagFilterAll) { + setTagGroups(groups?.filter(g => g.name != "All").map(g => g.name) || []) + } else { + setTagGroups(groups?.map(g => g.name) || []) + } + }, [groups]) + + return { + tagRender, + handleChangeTags, + dropDownRender, + optionRender, + tagGroups, + selectedTagGroups, + setGroupTagFilterAll, + getExistingAndToCreateGroupsLists, + getGroupNamesFromIDs, + selectValidator + } +} \ No newline at end of file diff --git a/src/views/AccessControl.tsx b/src/views/AccessControl.tsx index 0b9daa0..07c23e4 100644 --- a/src/views/AccessControl.tsx +++ b/src/views/AccessControl.tsx @@ -212,7 +212,7 @@ export const AccessControl = () => { {ruleToAction && <> Delete rule "{ruleToAction ? ruleToAction.name : ''}" - Are you sure you want to delete peer from your account? + Are you sure you want to delete this rule from your account? } , @@ -342,8 +342,8 @@ export const AccessControl = () => { }) return ( diff --git a/src/views/DNS.tsx b/src/views/DNS.tsx new file mode 100644 index 0000000..cbbb94a --- /dev/null +++ b/src/views/DNS.tsx @@ -0,0 +1,445 @@ +import React, {useEffect, useState} from 'react'; +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "typesafe-actions"; +import {actions as nsGroupActions} from '../store/nameservers'; +import {Container} from "../components/Container"; +import { + Alert, + Button, + Card, + Col, + Dropdown, + Input, + Menu, message, Modal, + Popover, Radio, RadioChangeEvent, + Row, + Select, + Space, + Table, + Tag, + Typography, +} from "antd"; +import {filter} from "lodash"; +import tableSpin from "../components/Spin"; +import {useGetAccessTokenSilently} from "../utils/token"; +import {actions as groupActions} from "../store/group"; +import {Group} from "../store/group/types"; +import {TooltipPlacement} from "antd/es/tooltip"; +import {NameServerGroup, NameServer} from "../store/nameservers/types"; +import NameServerGroupUpdate from "../components/NameServerGroupUpdate"; +import {ExclamationCircleOutlined} from "@ant-design/icons"; +import {useGetGroupTagHelpers} from "../utils/groups"; + +const {Title, Paragraph} = Typography; +const {Column} = Table; +const {confirm} = Modal; + +interface NameserverGroupDataTable extends NameServerGroup { + key: string +} + +const styleNotification = {marginTop: 85} + +export const DNS = () => { + const {getAccessTokenSilently} = useGetAccessTokenSilently() + const dispatch = useDispatch() + + const { + getGroupNamesFromIDs, + } = useGetGroupTagHelpers() + + const groups = useSelector((state: RootState) => state.group.data) + const nsGroup = useSelector((state: RootState) => state.nameserverGroup.data); + const failed = useSelector((state: RootState) => state.nameserverGroup.failed); + const loading = useSelector((state: RootState) => state.nameserverGroup.loading); + const updateNameServerGroupVisible = useSelector((state: RootState) => state.nameserverGroup.setupNewNameServerGroupVisible) + const savedNSGroup = useSelector((state: RootState) => state.nameserverGroup.savedNameServerGroup) + + const [groupPopupVisible, setGroupPopupVisible] = useState(false as boolean | undefined) + const [nsGroupToAction, setNsGroupToAction] = useState(null as NameserverGroupDataTable | null); + const [textToSearch, setTextToSearch] = useState(''); + const [optionAllEnable, setOptionAllEnable] = useState('enabled'); + const [pageSize, setPageSize] = useState(10); + const [dataTable, setDataTable] = useState([] as NameserverGroupDataTable[]); + const pageSizeOptions = [ + {label: "5", value: "5"}, + {label: "10", value: "10"}, + {label: "15", value: "15"} + ] + + const optionsAllEnabled = [{label: 'Enabled', value: 'enabled'}, {label: 'All', value: 'all'}] + + // setUserAndView makes the UserUpdate drawer visible (right side) and sets the user object + const setUserAndView = (nsGroup: NameServerGroup) => { + dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(true)); + dispatch(nsGroupActions.setNameServerGroup({ + id: nsGroup.id, + name: nsGroup.name, + primary: nsGroup.primary, + domains: nsGroup.domains, + description: nsGroup.description, + nameservers: nsGroup.nameservers, + groups: nsGroup.groups, + enabled: nsGroup.enabled, + } as NameServerGroup)); + } + + const transformDataTable = (d: NameServerGroup[]): NameserverGroupDataTable[] => { + return d.map(p => ({key: p.id, ...p} as NameserverGroupDataTable)) + } + + useEffect(() => { + dispatch(nsGroupActions.getNameServerGroups.request({getAccessTokenSilently: getAccessTokenSilently, payload: null})); + dispatch(groupActions.getGroups.request({getAccessTokenSilently: getAccessTokenSilently, payload: null})); + }, []) + + useEffect(() => { + setDataTable(transformDataTable(filterDataTable())) + }, [nsGroup]) + + useEffect(() => { + setDataTable(transformDataTable(filterDataTable())) + }, [textToSearch, optionAllEnable]) + + const filterDataTable = (): NameServerGroup[] => { + const t = textToSearch.toLowerCase().trim() + let f = filter(nsGroup, (f: NameServerGroup) => + ((f.name ).toLowerCase().includes(t) || + f.name.includes(t) || t === "" || + getGroupNamesFromIDs(f.groups).find(u => u.toLowerCase().trim().includes(t) || + f.domains.find(d => d.toLowerCase().trim().includes(t)) || + f.nameservers.find(n => n.ip.includes(t)))) + ) as NameServerGroup[] + if (optionAllEnable !== "all") { + f = filter(f, (f) => f.enabled) + } + return f + } + + const onChangeAllEnabled = ({target: {value}}: RadioChangeEvent) => { + setOptionAllEnable(value) + } + + const onChangeTextToSearch = (e: React.ChangeEvent) => { + setTextToSearch(e.target.value) + }; + + const searchDataTable = () => { + setDataTable(transformDataTable(filterDataTable())) + } + + const onChangePageSize = (value: string) => { + setPageSize(parseInt(value.toString())) + } + + const onClickEdit = () => { + dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(true)); + dispatch(nsGroupActions.setNameServerGroup({ + id: nsGroupToAction?.id, + name: nsGroupToAction?.name, + primary: nsGroupToAction?.primary, + domains: nsGroupToAction?.domains, + description: nsGroupToAction?.description, + groups: nsGroupToAction?.groups, + enabled: nsGroupToAction?.enabled, + nameservers: nsGroupToAction?.nameservers, + } as NameServerGroup)); + } + + const showConfirmDelete = () => { + confirm({ + icon: , + width: 600, + content: + {nsGroupToAction && + <> + Delete Nameserver group "{nsGroupToAction ? nsGroupToAction.name : ''}" + Are you sure you want to delete this nameserver group from your account? + + } + , + okType: 'danger', + onOk() { + dispatch(nsGroupActions.deleteNameServerGroup.request({ + getAccessTokenSilently: getAccessTokenSilently, + payload: nsGroupToAction?.id || '' + })); + }, + onCancel() { + setNsGroupToAction(null); + }, + }); + } + + const renderPopoverGroups = (label: string, rowGroups: string[] | null, userToAction: NameserverGroupDataTable) => { + + let groupsMap = new Map(); + groups.forEach(g => { + groupsMap.set(g.id!, g) + }) + + let displayGroups: Group[] = [] + if (rowGroups) { + displayGroups = rowGroups.filter(g => groupsMap.get(g)).map(g => groupsMap.get(g)!) + } + + let btn = + if (!displayGroups || displayGroups!.length < 1) { + return btn + } + + const content = displayGroups?.map((g, i) => { + const _g = g as Group + const peersCount = ` - ${_g.peers_count || 0} ${(!_g.peers_count || parseInt(_g.peers_count) !== 1) ? 'peers' : 'peer'} ` + return ( +
+ + {_g.name} + + {peersCount} +
+ ) + }) + const mainContent = ({content}) + let popoverPlacement = "top" + if (content && content.length > 5) { + popoverPlacement = "rightTop" + } + + return ( + + {btn} + + ) + } + + const renderPopoverDomains = (_: string, inputDomains: string[] | null, userToAction: NameserverGroupDataTable) => { + var domains = [] as string[] + if (inputDomains?.length) { + domains = inputDomains + } + + let btn = + if (!domains || domains!.length < 1) { + return btn + } + + const content = domains?.map((d, i) => { + return ( +
+ + {d} + +
+ ) + }) + + const mainContent = ({content}) + let popoverPlacement = "top" + if (content && content.length > 5) { + popoverPlacement = "rightTop" + } + + return ( + + {btn} + + ) + } + + useEffect(() => { + if (updateNameServerGroupVisible) { + setGroupPopupVisible(false) + } + }, [updateNameServerGroupVisible]) + + const createKey = 'saving'; + useEffect(() => { + if (savedNSGroup.loading) { + message.loading({content: 'Saving...', key: createKey, duration: 0, style: styleNotification}); + } else if (savedNSGroup.success) { + message.success({ + content: 'User has been successfully saved.', + key: createKey, + duration: 2, + style: styleNotification + }); + dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(false)); + dispatch(nsGroupActions.setSavedNameServerGroup({...savedNSGroup, success: false})); + dispatch(nsGroupActions.resetSavedNameServerGroup(null)) + } else if (savedNSGroup.error) { + message.error({ + content: 'Failed to update user. You might not have enough permissions.', + key: createKey, + duration: 2, + style: styleNotification + }); + dispatch(nsGroupActions.setSavedNameServerGroup({...savedNSGroup, error: null})); + dispatch(nsGroupActions.resetSavedNameServerGroup(null)) + } + }, [savedNSGroup]) + + const onPopoverVisibleChange = () => { + if (updateNameServerGroupVisible) { + setGroupPopupVisible(false) + } else { + setGroupPopupVisible(undefined) + } + } + + const itemsMenuAction = [ + { + key: "edit", + label: () + }, + { + key: "delete", + label: () + }, + ] + + const actionsMenu = () + + const onClickAddNewNSGroup = () => { + dispatch(nsGroupActions.setSetupNewNameServerGroupVisible(true)); + dispatch(nsGroupActions.setNameServerGroup({ + enabled: true, + } as NameServerGroup)) + } + + return ( + <> + + + + Nameservers + Add nameservers for domain name resolution in your NetBird network + + + + + + + + +