Files
netbird-dashboard/src/components/NameServerGroupAdd.tsx
2023-12-08 11:07:10 +01:00

953 lines
29 KiB
TypeScript

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,
Switch,
Form,
FormListFieldData,
Input,
InputNumber,
message,
Modal,
Row,
Select,
Space,
Typography,
} from "antd";
import {
CloseOutlined,
MinusCircleOutlined,
PlusOutlined,
} 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 { useGetTokenSilently } from "../utils/token";
import { domainValidator } from "../utils/common";
const { Paragraph, Text } = Typography;
interface formNSGroup extends NameServerGroup {}
const NameServerGroupAdd = () => {
const {
blueTagRender,
handleChangeTags,
dropDownRender,
optionRender,
tagGroups,
getExistingAndToCreateGroupsLists,
getGroupNamesFromIDs,
selectValidator,
} = useGetGroupTagHelpers();
const dispatch = useDispatch();
const { getTokenSilently } = useGetTokenSilently();
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<any>(null);
const inputDescriptionRef = useRef<any>(null);
const [selectCustom, setSelectCustom] = useState(false);
const [matchDomains, setMatchDomains] = useState(0);
useEffect(() => {
if (editName)
inputNameRef.current!.focus({
cursor: "end",
});
}, [editName]);
useEffect(() => {
if (editDescription)
inputDescriptionRef.current!.focus({
cursor: "end",
});
}, [editDescription]);
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,
search_domains_enabled: false,
} as NameServerGroup)
);
setEditName(false);
setSelectCustom(false);
setIsPrimary(false);
};
const onChange = (changedValues: any) => {
if (changedValues.primary !== undefined) {
setIsPrimary(changedValues.primary);
}
setFormNSGroup({ ...formNSGroup, ...changedValues });
};
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,
search_domains_enabled: false,
},
{
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,
search_domains_enabled: false,
},
{
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,
search_domains_enabled: false,
},
];
const handleSelectChange = (value: string) => {
let nsGroupLocal = {} as NameServerGroup;
if (value === customChoice) {
nsGroupLocal = nsGroup;
nsGroupLocal.domains = [];
} 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: getTokenSilently,
payload: nsGroupToSave,
})
);
})
.then(() => onCancel())
.catch((errorInfo) => {
let msg = "please check the fields and try again";
if (errorInfo.errorFields) {
msg = errorInfo.errorFields[0].errors[0];
}
message.error({
content: msg,
duration: 1,
});
});
};
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.domains.length ? false : true,
domains: values.primary ? [] : values.domains,
nameservers: values.nameservers,
groups: existingGroups,
groupsToCreate: newGroups,
enabled: values.enabled,
search_domains_enabled:
matchDomains > 0 ? formNSGroup.search_domains_enabled : false,
} as NameServerGroupToSave;
};
const toggleEditName = (status: boolean) => {
setEditName(status);
};
const toggleEditDescription = (status: boolean) => {
setEditDescription(status);
};
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 }: any,
{ errors }: any
) => (
<div style={{ width: "100%", maxWidth: "360px" }}>
<label
style={{
color: "rgba(0, 0, 0, 0.88)",
fontSize: "14px",
fontWeight: "500",
marginBottom: "10px",
display: "block",
}}
>
Nameservers
</label>
{!!fields.length && (
<Row align="middle">
<Col span={4} style={{ textAlign: "left" }}>
<Typography.Text
style={{ color: "#818183", paddingLeft: "5px" }}
></Typography.Text>
</Col>
<Col span={10} style={{ textAlign: "left" }}>
<Typography.Text style={{ color: "#818183", paddingLeft: "5px" }}>
Nameserver IP
</Typography.Text>
</Col>
<Col span={4} style={{ textAlign: "left" }}>
<Typography.Text style={{ color: "#818183", paddingLeft: "5px" }}>
Port
</Typography.Text>
</Col>
<Col span={4} />
</Row>
)}
{fields.map((field, index) => {
return (
<Row key={index}>
<Col span={4} style={{ textAlign: "left" }}>
<Form.Item
style={{ margin: "3px" }}
name={[field.name, "ns_type"]}
rules={[{ required: true, message: "Missing first protocol" }]}
initialValue={"udp"}
>
<Select
disabled
style={{ width: "100%" }}
className="style-like-text"
>
<Option value="udp">UDP</Option>
</Select>
</Form.Item>
</Col>
<Col span={10} style={{ margin: "1px" }}>
<Form.Item
style={{ margin: "1px" }}
name={[field.name, "ip"]}
rules={[{ validator: ipValidator }]}
>
<Input
placeholder="e.g. X.X.X.X"
style={{ width: "100%" }}
autoComplete="off"
/>
</Form.Item>
</Col>
<Col span={4} style={{ textAlign: "center" }}>
<Form.Item
style={{ margin: "1px" }}
name={[field.name, "port"]}
rules={[{ required: true, message: "Missing port" }]}
initialValue={53}
>
<InputNumber placeholder="Port" style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col
span={2}
style={{
textAlign: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<MinusCircleOutlined onClick={() => remove(field.name)} />
</Col>
</Row>
);
})}
<Row>
<Col span={20}>
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
style={{
maxWidth: "270px",
marginTop: "5px",
}}
disabled={fields.length > 1}
icon={<PlusOutlined />}
>
Add nameserver
</Button>
<Form.ErrorList errors={errors} />
</Form.Item>
</Col>
</Row>
</div>
);
// @ts-ignore
const renderDomains = (
fields: FormListFieldData[],
{ add, remove }: any,
{ errors }: any
) => (
<>
<Row>
<Space>
<Col>
<label
style={{
color: "rgba(0, 0, 0, 0.88)",
fontSize: "14px",
fontWeight: "500",
}}
>
Match domains
</label>
<Paragraph
type={"secondary"}
style={{
marginTop: "-4px",
fontWeight: "400",
marginBottom: "4px",
}}
>
Add domain if you want to have a specific one
</Paragraph>
</Col>
</Space>
</Row>
{setMatchDomains(fields.length)}
{fields.map((field, index) => {
return (
<Row key={index} style={{ marginBottom: "5px" }}>
<Col span={22}>
<Form.Item
style={{ margin: "0" }}
{...field}
rules={[{ validator: domainValidator }]}
>
<Input
placeholder="e.g. example.com"
style={{ width: "100%" }}
autoComplete="off"
/>
</Form.Item>
</Col>
<Col
span={2}
style={{
textAlign: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
/>
</Col>
</Row>
);
})}
<Row>
<Col span={24} style={{ margin: "1px" }}>
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
style={{ marginTop: "5px" }}
>
Add Domain
</Button>
</Form.Item>
</Col>
</Row>
<Form.ErrorList errors={errors} />
</>
);
const handleChangeDisabled = (checked: boolean) => {
setFormNSGroup({
...formNSGroup,
enabled: checked,
});
};
const handleChangeMarkDomain = (checked: boolean) => {
setFormNSGroup({
...formNSGroup,
search_domains_enabled: checked,
});
};
return (
<>
{nsGroup && (
<Modal
forceRender={true}
footer={false}
onCancel={onCancel}
open={setupNewNameServerGroupVisible}
>
<Row gutter={16}>
<Col span={24}>
<Paragraph
style={{
textAlign: "start",
whiteSpace: "pre-line",
fontSize: "18px",
fontWeight: "500",
}}
>
Add nameserver
</Paragraph>
<Paragraph
type={"secondary"}
style={{
textAlign: "start",
whiteSpace: "pre-line",
marginTop: "-20px",
fontSize: "14px",
paddingBottom: "25px",
marginBottom: "4px",
}}
>
Use this nameserver to resolve domains in your network
</Paragraph>
</Col>
</Row>
{selectCustom ? (
<Form
layout="vertical"
requiredMark={false}
form={form}
onValuesChange={onChange}
>
<Row gutter={16}>
<Col span={24}>
<Header
style={{
border: "none",
}}
>
<Row align="top">
<Col flex="none" style={{ display: "flex" }}>
{!editName && !editDescription && formNSGroup.id && (
<button
type="button"
aria-label="Close"
className="ant-drawer-close"
style={{ paddingTop: 3 }}
onClick={onCancel}
>
<span
role="img"
aria-label="close"
className="anticon anticon-close"
>
<CloseOutlined size={16} />
</span>
</button>
)}
</Col>
<Col flex="auto">
{!editName && formNSGroup.id ? (
<div
className={
"access-control input-text ant-drawer-title"
}
onClick={() => toggleEditName(true)}
>
{formNSGroup.id
? formNSGroup.name
: "New nameserver group"}
</div>
) : (
<div style={{ lineHeight: "15px" }}>
<label
style={{
color: "rgba(0, 0, 0, 0.88)",
fontSize: "14px",
fontWeight: "500",
}}
>
Name
</label>
<Form.Item
name="name"
rules={[
{
required: true,
message:
"Please add an identifier for this nameserver group",
whitespace: true,
},
{
validator: nameValidator,
},
]}
style={{
marginBottom: "10px",
marginTop: "10px",
}}
>
<Input
placeholder="e.g. Public DNS"
ref={inputNameRef}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)}
autoComplete="off"
maxLength={40}
/>
</Form.Item>
</div>
)}
{!editDescription ? (
<div
className={
"access-control input-text ant-drawer-subtitle"
}
style={{ marginTop: "0" }}
onClick={() => toggleEditDescription(true)}
>
{formNSGroup.description &&
formNSGroup.description.trim() !== ""
? formNSGroup.description
: "Add description"}
</div>
) : (
<div
style={{ lineHeight: "15px", marginTop: "24px" }}
>
<label
style={{
color: "rgba(0, 0, 0, 0.88)",
fontSize: "14px",
fontWeight: "500",
}}
>
Description
</label>
<Form.Item
name="description"
style={{ marginTop: "8px" }}
>
<Input
placeholder="Add description..."
ref={inputDescriptionRef}
onPressEnter={() =>
toggleEditDescription(false)
}
onBlur={() => toggleEditDescription(false)}
autoComplete="off"
/>
</Form.Item>
</div>
)}
</Col>
</Row>
</Header>
</Col>
<Col span={24}>
<Form.List
name="nameservers"
rules={[{ validator: formListValidator }]}
>
{renderNSList}
</Form.List>
</Col>
<Col span={24}>
<Form.List name="domains">{renderDomains}</Form.List>
</Col>
{matchDomains > 0 && (
<Col span={24}>
<Form.Item name="search_domains_enabled" label="">
<div
style={{
display: "flex",
gap: "15px",
}}
>
<Switch
onChange={handleChangeMarkDomain}
size="small"
/>
<div>
<label
style={{
color: "rgba(0, 0, 0, 0.88)",
fontSize: "14px",
fontWeight: "500",
}}
>
Mark match domains as search domains
</label>
<Paragraph
type={"secondary"}
style={{
marginTop: "-2",
fontWeight: "400",
marginBottom: "0",
}}
>
E.g., "peer.example.com" will be accessible with
"peer"
</Paragraph>
</div>
</div>
</Form.Item>
</Col>
)}
<Col span={24}>
<label
style={{
color: "rgba(0, 0, 0, 0.88)",
fontSize: "14px",
fontWeight: "500",
}}
>
Distribution groups
</label>
<Paragraph
type={"secondary"}
style={{
marginTop: "-4px",
fontWeight: "400",
marginBottom: "4px",
}}
>
Advertise this route to peers that belong to the following
groups
</Paragraph>
<Form.Item
name="groups"
rules={[{ validator: selectValidator }]}
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Associate groups with the NS group"
tagRender={blueTagRender}
onChange={handleChangeTags}
dropdownRender={dropDownRender}
optionFilterProp="serchValue"
>
{tagGroups.map((m, index) => (
<Option key={index} value={m.id} serchValue={m.name}>
{optionRender(m.name, m.id)}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="enabled" label="">
<div
style={{
display: "flex",
gap: "15px",
}}
>
<Switch
onChange={handleChangeDisabled}
defaultChecked={formNSGroup.enabled}
size="small"
/>
<div>
<label
style={{
color: "rgba(0, 0, 0, 0.88)",
fontSize: "14px",
fontWeight: "500",
}}
>
Enabled
</label>
<Paragraph
type={"secondary"}
style={{
marginTop: "-2",
fontWeight: "400",
marginBottom: "0",
}}
>
{formNSGroup.enabled
? "Disable this server if you don't want it to apply immediately"
: " Enable this server if you want it to apply immediately"}
</Paragraph>
</div>
</div>
</Form.Item>
</Col>
<Col
span={24}
style={{ marginTop: "10px", marginBottom: "24px" }}
>
<Text type={"secondary"}>
Learn more about
<a
target="_blank"
rel="noreferrer"
href="https://docs.netbird.io/how-to/manage-dns-in-your-network"
>
{" "}
DNS
</a>
</Text>
</Col>
<Col
style={{
width: "100%",
}}
>
<Space
style={{
display: "flex",
justifyContent: "end",
width: "100%",
}}
>
<Button onClick={onCancel} disabled={savedNSGroup.loading}>
Cancel
</Button>
<Button
type="primary"
onClick={handleFormSubmit}
disabled={savedNSGroup.loading}
>
Create nameserver
</Button>
</Space>
</Col>
</Row>
</Form>
) : (
<>
<Space direction={"vertical"} style={{ width: "100%" }}>
<Row align="middle">
<Col span={24} style={{ textAlign: "left" }}>
<span className="ant-form-item font-500">
Select a predefined one
</span>
</Col>
</Row>
<Row align="middle">
<Col span={24} style={{ textAlign: "center" }}>
<Select
style={{ width: "100%" }}
onChange={handleSelectChange}
options={[
{
value: googleChoice,
label: googleChoice,
},
{
value: cloudflareChoice,
label: cloudflareChoice,
},
{
value: quad9Choice,
label: quad9Choice,
},
{
value: customChoice,
label: customChoice,
},
]}
/>
</Col>
</Row>
<Row align="middle">
<Col span={24} style={{ textAlign: "left" }}>
<Col span={24} style={{ textAlign: "left" }}>
<span className="ant-form-item blue-color">
<Typography.Link
onClick={() => handleSelectChange(customChoice)}
>
or create custom
</Typography.Link>
</span>
</Col>
</Col>
</Row>
</Space>
<Space
style={{
display: "flex",
justifyContent: "end",
marginTop: "25px",
}}
>
<Button onClick={onCancel} type="primary">
Cancel
</Button>
</Space>
</>
)}
</Modal>
)}
</>
);
};
export default NameServerGroupAdd;