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 + + + + + + + + +