Compare commits

..
3 Commits
Author SHA1 Message Date
pascal-fischer ac42a17b11 remove unnecessary oidc user check from users page (#166) 2023-04-27 13:28:50 +02:00
pascal-fischer 77ca3c6fde Adding service user support and new user overview (#164)
Changes:
user tab was split in service users and regular users
user edit view was reworked:
shows PAT box for service users and self (hides for rest)
hides email and groups for service users as no usage
reverted settings tab to only contain account settings + hide for normal users
Use navbar avatar dropdown to link to users list and open user edit page for self
extend api-client to handle requests with query parameters
use popup form for PAT creation, user invite and service user creation
validate all form fields before trying to send API call and show faulty fields

Additional fixes:
groups popup was only visible after 2nd hover after tab switch on every view, fixed to also show on first hover
fix setup keys page throwing errors from time to time and not loading
peers view was sending getRoute requests that are only allowed for admins which was throwing errors (only console) for normal users -> disabled requests for non-admin users
2023-04-22 12:57:17 +02:00
pascal-fischer 20e24b4ede Fix Routes view when updating routes (#165)
* fix filter

* filter also for android

* fix masquerade Traffic button

* remove log
2023-04-21 13:07:54 +02:00
42 changed files with 2527 additions and 1615 deletions

No files matched your search

+1
View File
@@ -77,6 +77,7 @@ function App() {
colorPrimary: "#1890ff",
fontFamily: "Arial"
},
components: {Badge: {fontSizeSM: 20}},
}}
>
<Provider store={store}>
+21 -2
View File
@@ -12,6 +12,7 @@ import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as userActions} from "../store/user";
import {useGetTokenSilently} from "../utils/token";
import {actions as personalAccessTokenActions} from "../store/personal-access-token";
const {useBreakpoint} = Grid;
@@ -47,11 +48,25 @@ const Navbar = () => {
const userEmailKey = 'user-email'
const userLogoutKey = 'user-logout'
const userDividerKey = 'user-divider'
const adminOnlyTabs = ["/setup-keys", "/acls", "/routes", "/dns", "/activity"]
const adminOnlyTabs = ["/setup-keys", "/acls", "/routes", "/dns", "/activity", "/settings"]
const [menuItems, setMenuItems] = useState(items)
const logoutWithRedirect = () =>
logout("/", {client_id: config.clientId});
const openPersonalUserPage = () => {
dispatch(userActions.setUser({
id: currentUser.id,
email: currentUser.email,
role: currentUser.role,
auto_groups: currentUser.auto_groups ? currentUser.auto_groups : [],
name: currentUser.name,
is_current: currentUser.is_current,
is_service_user: currentUser.is_service_user,
} as User));
dispatch(userActions.setUserTabOpen("Users"));
dispatch(userActions.setEditUserPopupVisible(true));
}
useEffect(() => {
const fs = items.filter(m => showTab(m?.key?.toString(), currentUser) && m?.key !== userEmailKey && m?.key !== userLogoutKey && m?.key !== userDividerKey)
if (screens.xs === true) {
@@ -114,7 +129,7 @@ const Navbar = () => {
<Menu
items={[
{
label: <>{user?.email}</>,
label: (<Link to="/users" onClick={openPersonalUserPage}>{user?.email}</Link>),
key: '0',
},
{
@@ -148,6 +163,10 @@ const Navbar = () => {
<Col flex="1 1 auto">
<div>
<Menu mode="horizontal" selectable={true} selectedKeys={[location.pathname]}
onSelect={(e) => {
dispatch(userActions.setUser(null as unknown as User));
dispatch(personalAccessTokenActions.resetPersonalAccessTokens(null))
}}
defaultSelectedKeys={[location.pathname]} items={menuItems}/>
</div>
</Col>
+9 -4
View File
@@ -123,7 +123,8 @@ const RouteUpdate = () => {
peers.forEach((p) => {
let os: string
os = p.os
if (!os.toLowerCase().startsWith("darwin") && !os.toLowerCase().startsWith("windows")) {
if (!os.toLowerCase().startsWith("darwin") && !os.toLowerCase().startsWith("windows") && !os.toLowerCase().startsWith("android")
&& route && !routes.filter(r => r.network_id === route.network_id).find(r => r.peer === p.id)) {
options?.push({
label: peerToPeerIP(p.name, p.ip),
value: peerToPeerIP(p.name, p.ip),
@@ -135,10 +136,14 @@ const RouteUpdate = () => {
const createRouteToSave = (inputRoute: FormRoute): RouteToSave => {
let peerIDList = inputRoute.peer.split(routePeerSeparator)
let peerID: string
if (peerIDList[1]) {
peerID = peerIPToID[peerIDList[1]]
if (peerIDList.length === 1) {
peerID = inputRoute.peer
} else {
peerID = peerIPToID[peerNameToIP[inputRoute.peer]]
if (peerIDList[1]) {
peerID = peerIPToID[peerIDList[1]]
} else {
peerID = peerIPToID[peerNameToIP[inputRoute.peer]]
}
}
let [ existingGroups, groupsToCreate ] = getExistingAndToCreateGroupsLists(inputRoute.groups)
+1 -1
View File
@@ -261,7 +261,7 @@ const SetupKeyNew = () => {
}
const groupsChanged = (): boolean => {
if (formSetupKey.autoGroupNames.length != setupKey.auto_groups.length) {
if (setupKey && setupKey.auto_groups && formSetupKey.autoGroupNames.length != setupKey.auto_groups.length) {
return true
}
const formGroupIds = groups?.filter(g => formSetupKey.autoGroupNames.includes(g.name)).map(g => g.id || '') || []
+481
View File
@@ -0,0 +1,481 @@
import {
Badge,
Breadcrumb,
Button,
Col,
Divider,
Form,
Input,
List, Modal,
Row,
Select,
Skeleton,
Space, Table,
Tag, Typography
} from "antd";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as userActions} from "../store/user";
import {FormUser, User, UserToSave} from "../store/user/types";
import {useGetTokenSilently} from "../utils/token";
import React, {useEffect, useState} from "react";
import {RuleObject} from "antd/lib/form";
import {CustomTagProps} from "rc-select/lib/BaseSelect";
import {actions as groupActions} from "../store/group";
import {actions as personalAccessTokenActions} from "../store/personal-access-token";
import {PersonalAccessToken, PersonalAccessTokenCreate, SpecificPAT} from "../store/personal-access-token/types";
import tableSpin from "./Spin";
import AddPATPopup from "./popups/AddPATPopup";
import {fullDate} from "../utils/common";
import {ExclamationCircleOutlined} from "@ant-design/icons";
import {Container} from "./Container";
import Column from "antd/lib/table/Column";
import {useOidcUser} from "@axa-fr/react-oidc";
const {Option} = Select;
const {Title, Paragraph, Text} = Typography;
interface TokenDataTable extends PersonalAccessToken {
key: string
status: string
created_by_email: string
}
const UserEdit = () => {
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const groups = useSelector((state: RootState) => state.group.data)
const users = useSelector((state: RootState) => state.user.data)
const user = useSelector((state: RootState) => state.user.user)
const savedUser = useSelector((state: RootState) => state.user.savedUser)
const personalAccessTokens = useSelector((state: RootState) => state.personalAccessToken.data);
const tab = useSelector((state: RootState) => state.user.userTabOpen)
const loading = useSelector((state: RootState) => state.user.loading);
const {oidcUser} = useOidcUser();
const [tokenTable, setTokenTable] = useState([] as TokenDataTable[]);
const [tagGroups, setTagGroups] = useState([] as string[])
const [currentGroups, setCurrentGroups] = useState([] as string[])
const [formUser, setFormUser] = useState({} as FormUser)
const [form] = Form.useForm()
const [confirmModal, confirmModalContextHolder] = Modal.useModal();
const onCancel = () => {
if (savedUser.loading) return
dispatch(userActions.setUser(null as unknown as User));
dispatch(personalAccessTokenActions.resetPersonalAccessTokens(null))
setFormUser({} as FormUser)
dispatch(userActions.setEditUserPopupVisible(false));
}
const createUserToSave = (values: any): UserToSave => {
const autoGroups = groups?.filter(g => values.autoGroupsNames.includes(g.name)).map(g => g.id || '') || []
// find groups that do not yet exist (newly added by the user)
const allGroupsNames: string[] = groups?.map(g => g.name);
const groupsToCreate = values.autoGroupsNames.filter((s: string) => !allGroupsNames.includes(s))
let userID = user ? user.id : ''
let isServiceUser = user ? user.is_service_user : false
return {
id: userID,
role: values.role,
name: values.name,
groupsToCreate: groupsToCreate,
auto_groups: autoGroups,
is_service_user: isServiceUser
} as UserToSave
}
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let userToSave = createUserToSave(values)
dispatch(userActions.saveUser.request({
getAccessTokenSilently: getTokenSilently,
payload: userToSave
}))
dispatch(userActions.setEditUserPopupVisible(false));
dispatch(userActions.setUser(null as unknown as User))
dispatch(personalAccessTokenActions.resetPersonalAccessTokens(null))
dispatch(groupActions.getGroups.request({
getAccessTokenSilently: getTokenSilently,
payload: null
}));
dispatch(userActions.getServiceUsers.request({
getAccessTokenSilently: getTokenSilently,
payload: null
}))
dispatch(userActions.getUsers.request({
getAccessTokenSilently: getTokenSilently,
payload: null
}))
})
.catch((errorInfo) => {
console.log('errorInfo', errorInfo)
});
};
const onClickAddNewPersonalAccessToken = () => {
dispatch(personalAccessTokenActions.setPersonalAccessToken({
user_id: "",
name: "",
expires_in: 7
} as PersonalAccessTokenCreate))
dispatch(personalAccessTokenActions.setNewPersonalAccessTokenPopupVisible(true));
}
const onBreadcrumbUsersClick = (key: string) => {
if (savedUser.loading) return
dispatch(userActions.setUser(null as unknown as User));
dispatch(personalAccessTokenActions.resetPersonalAccessTokens(null))
dispatch(userActions.setUserTabOpen(key))
}
const selectValidator = (_: RuleObject, value: string[]) => {
let hasSpaceNamed = []
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()
}
const tagRender = (props: CustomTagProps) => {
const {label, value, closable, onClose} = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Tag
color="blue"
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{marginRight: 3}}
>
<strong>{value}</strong>
</Tag>
);
}
const dropDownRender = (menu: React.ReactElement) => (
<>
{menu}
<Divider style={{margin: '8px 0'}}/>
<Row style={{padding: '0 8px 4px'}}>
<Col flex="auto">
<span style={{color: "#9CA3AF"}}>Add new group by pressing "Enter"</span>
</Col>
<Col flex="none">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z"
fill="#9CA3AF"/>
</svg>
</Col>
</Row>
</>
)
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 (
<>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{label}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</>
)
}
const transformTokenTable = (d: PersonalAccessToken[]): TokenDataTable[] => {
if(!d) {
return []
}
return d.map(p => ({
key: p.id,
status: Date.parse(p.expiration_date) > Date.now() ? "valid" : "expired",
created_by_email: getEmail(p),
...p} as TokenDataTable))
}
const getEmail = (token: PersonalAccessToken): string => {
return users.find(u => u.id === token.created_by)?.email || ""
}
const showConfirmDelete = (token: TokenDataTable) => {
confirmModal.confirm({
icon: <ExclamationCircleOutlined/>,
title: "Delete token \"" + token.name + "\"",
width: 600,
content: <Space direction="vertical" size="small">
<Paragraph>Are you sure you want to delete this token?</Paragraph>
</Space>,
onOk() {
dispatch(personalAccessTokenActions.deletePersonalAccessToken.request({
getAccessTokenSilently: getTokenSilently,
payload: {
user_id: user.id,
id: token.id,
name: token.name,
} as SpecificPAT
}));
},
onCancel() {
// noop
},
});
}
useEffect(() => {
setTokenTable(transformTokenTable(personalAccessTokens))
}, [personalAccessTokens, users])
useEffect(() => {
setTagGroups(groups?.filter(g => g.name != "All").map(g => g.name) || [])
}, [groups])
useEffect(() => {
if (user) {
// @ts-ignore
setCurrentGroups(groups.filter(g => g.name != "All" && user.auto_groups.includes(g.id)).map(g => g.name) || [])
}
}, [groups, user])
useEffect(() => {
dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}))
dispatch(groupActions.getGroups.request({
getAccessTokenSilently: getTokenSilently,
payload: null
}))
}, [])
useEffect(() => {
if (user.is_current || user.is_service_user) {
dispatch(personalAccessTokenActions.getPersonalAccessTokens.request({getAccessTokenSilently: getTokenSilently,
payload: user.id}))
}
}, [user])
useEffect(() => {
if (user && currentGroups) {
form.setFieldsValue({
name: user.name,
role: user.role,
email: user.email,
autoGroupsNames: currentGroups,
})
}
}, [form, user, currentGroups])
const menuItems = [
{
key: '1',
label: (
<Text onClick={() => onBreadcrumbUsersClick("Users")}>
Users
</Text>
),
},
{
key: '2',
label: (
<Text onClick={() => onBreadcrumbUsersClick("Service Users")}>
Service Users
</Text>
),
},
];
const isUserAdmin = (userId: string): boolean => {
return users.find(u => u.id === userId)?.role === "admin"
}
return (
<>
<div style={{paddingTop: "13px"}}>
<Breadcrumb style={{marginBottom: "30px"}}
items={[
{
title: <Text onClick={() => onBreadcrumbUsersClick("Users")}>All Users</Text>,
},
{
title: <Text onClick={() => onBreadcrumbUsersClick(tab)}>{tab}</Text>,
// menu: { items: menuItems },
},
{
title: user.name,
},
]}
/>
<Container style={{backgroundColor: "white", padding: "20px", borderRadius: "4px", boxSizing: "border-box", border: "0.5px solid #D9D9D9", marginBottom: "7px"}}>
<div style={{maxWidth: "800px"}}>
<Paragraph style={{textAlign: "left", whiteSpace: "pre-line", fontSize: "1.5em"}}>{user.name}</Paragraph>
<Form layout="vertical" hideRequiredMark form={form}
initialValues={{
name: formUser.name,
role: formUser.role,
email: formUser.email,
autoGroupsNames: formUser.autoGroupsNames,
}}
>
<Row style={{ paddingBottom: "15px"}}>
{!user.is_service_user && <Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
<Form.Item
name="email"
label={<Text style={{fontSize: "16px", fontWeight: "500"}}>Email</Text>}
style={{marginRight: "70px"}}
>
<Input
disabled={user.id}
value={formUser.email}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>}
<Col xs={24} sm={24} md={5} lg={5} xl={5} xxl={5} span={5}>
<Form.Item
name="role"
label={<Text style={{fontSize: "16px", fontWeight: "500"}}>Role</Text>}
style={{marginRight: "50px"}}
>
<Select style={{width: '100%'}}
disabled={user.is_current}>
<Option value="admin">admin</Option>
<Option value="user">user</Option>
</Select>
</Form.Item>
</Col>
</Row>
{!user.is_service_user && <Row style={{ paddingBottom: "15px"}}>
<Col span={9}>
<Form.Item
name="autoGroupsNames"
label={<Text style={{fontSize: "16px", fontWeight: "500"}}>Auto-assigned groups</Text>}
tooltip="Every peer enrolled with this user will be automatically added to these groups"
rules={[{validator: selectValidator}]}
>
<Select mode="tags"
style={{width: '100%'}}
placeholder="Associate groups with the user"
tagRender={tagRender}
dropdownRender={dropDownRender}
disabled={oidcUser && !isUserAdmin(oidcUser.sub)}
>
{
tagGroups.map(m =>
<Option key={m}>{optionRender(m)}</Option>
)
}
</Select>
</Form.Item>
</Col>
</Row>}
<Space style={{display: 'flex', justifyContent: 'start'}}>
<Button disabled={loading} onClick={onCancel}>Cancel</Button>
<Button type="primary"
onClick={handleFormSubmit}>Save</Button>
</Space>
</Form>
</div>
</Container>
{user && (user.is_current || user.is_service_user) && <Container style={{backgroundColor: "white", padding: "20px", borderRadius: "4px", boxSizing: "border-box", border: "0.5px solid #D9D9D9"}}>
<div style={{maxWidth: "800px"}}>
<Paragraph style={{textAlign: "left", whiteSpace: "pre-line", fontSize: "1.5em"}}>Access tokens</Paragraph>
<Row gutter={21} style={{marginTop: "-22px", marginBottom: "10px"}}>
<Col xs={24} sm={24} md={20} lg={20} xl={20} xxl={20} span={20}>
<Paragraph type={"secondary"} style={{textAlign: "left", whiteSpace: "pre-line"}}>Access tokens give access to the Netbird API</Paragraph>
</Col>
<Col xs={24} sm={24} md={1} lg={1} xl={1} xxl={1} span={1} style={{marginTop: "-16px"}}>
{personalAccessTokens && personalAccessTokens.length > 0 && <Button type="primary" onClick={onClickAddNewPersonalAccessToken}>Create Token</Button>}
</Col>
</Row>
{personalAccessTokens && personalAccessTokens.length > 0 &&
<Table
size={"small"}
style={{marginTop: "-10px"}}
showHeader={false}
scroll={{x: 800}}
pagination={false}
loading={tableSpin(loading)}
dataSource={tokenTable}>
<Column className={"non-highlighted-table-column"} sorter={(a, b) => ((a as TokenDataTable).created_at.localeCompare((b as TokenDataTable).created_at))}
defaultSortOrder='descend'
render={(text, record, index) => {
return (<>
<Row>
<Col>
<Badge status={(record as TokenDataTable).status === "valid" ? "success" : "error"} style={{marginTop: "1px", marginRight: "5px", marginLeft: "0px"}}/>
</Col>
<Col>
<Paragraph style={{fontSize: "16px", fontWeight: "500", margin: "0px", padding: "0px"}}>{(record as TokenDataTable).name}</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "13px", fontWeight: "400", margin: "0px", marginTop: "-2px", padding: "0px"}}>{"Created" + ((record as TokenDataTable).created_by_email && user.is_service_user ? " by " + (record as TokenDataTable).created_by_email : "") + " on " + fullDate((record as TokenDataTable).created_at)}</Paragraph>
</Col>
</Row>
</>)
}}/>
<Column render={(text, record, index) => {
return <>
<Paragraph type={"secondary"} style={{textAlign: "left", fontSize: "11px"}}>Expires on</Paragraph>
<Paragraph type={"secondary"} style={{textAlign: "left", marginTop: "-10px", marginBottom: "0", fontSize: "15px"}}>{fullDate((record as TokenDataTable).expiration_date)}</Paragraph>
</>
}}
/>
<Column render={(text, record, index) => {
return <>
<Paragraph type={"secondary"} style={{textAlign: "left", fontSize: "11px"}}>Last used</Paragraph>
<Paragraph type={"secondary"} style={{textAlign: "left", marginTop: "-10px", marginBottom: "0", fontSize: "15px"}}>{(record as TokenDataTable).last_used ? fullDate((record as TokenDataTable).last_used) : "Never"}</Paragraph>
</>
}}
/>
<Column align="right"
render={(text, record, index) => {
return (
<Button danger={true} type={"text"}
onClick={() => {
showConfirmDelete(record as TokenDataTable)
}}
>Delete</Button>
)
}}
/>
</Table>}
<Divider style={{marginTop: "-12px"}}></Divider>
{(personalAccessTokens === null || personalAccessTokens.length === 0) && <Space direction="vertical" size="small" align="start"
style={{display: 'flex', padding: '45px 0px', marginTop: "-40px", justifyContent: 'center'}}>
<Paragraph
style={{textAlign: "start", whiteSpace: "pre-line", fontSize: "16px"}}>
You don’t have any access tokens yet.
</Paragraph>
<Button type="primary" onClick={onClickAddNewPersonalAccessToken}>Create Token</Button>
</Space>}
</div>
</Container>}
</div>
<AddPATPopup/>
{confirmModalContextHolder}
</>
)
}
export default UserEdit;
-414
View File
@@ -1,414 +0,0 @@
import React, {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {Alert, Button, Col, Divider, Drawer, Form, Input, Modal, Row, Select, Space, Tag, Typography} from "antd";
import {RootState} from "typesafe-actions";
import {CloseOutlined, EditOutlined, ExclamationCircleOutlined} from "@ant-design/icons";
import {Header} from "antd/es/layout/layout";
import {Group} from "../store/group/types";
import {FormUser, User, UserToSave} from "../store/user/types";
import {RuleObject} from "antd/lib/form";
import {CustomTagProps} from "rc-select/lib/BaseSelect";
import {actions as userActions} from "../store/user";
import {useGetTokenSilently} from "../utils/token";
import {useOidcUser} from "@axa-fr/react-oidc";
const {Paragraph} = Typography;
const {confirm} = Modal;
const {Option} = Select;
const UserUpdate = () => {
const {oidcUser} = useOidcUser();
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const user = useSelector((state: RootState) => state.user.user)
const savedUser = useSelector((state: RootState) => state.user.savedUser)
const groups = useSelector((state: RootState) => state.group.data)
const users = useSelector((state: RootState) => state.user.data)
const updateUserDrawerVisible = useSelector((state: RootState) => state.user.updateUserDrawerVisible)
const [selectedTagGroups, setSelectedTagGroups] = useState([] as string[])
const [tagGroups, setTagGroups] = useState([] as string[])
const [editName, setEditName] = useState(false)
const inputNameRef = useRef<any>(null)
const [formUser, setFormUser] = useState({} as FormUser)
const [currentUser, setCurrentUser] = useState({} as User)
const [form] = Form.useForm()
useEffect(() => {
if (editName) inputNameRef.current!.focus({
cursor: 'end',
});
}, [editName]);
const toggleEditName = (status: boolean) => {
setEditName(status);
}
useEffect(() => {
setTagGroups(groups?.filter(g => g.name != "All").map(g => g.name) || [])
}, [groups])
useEffect(() => {
if (oidcUser && oidcUser.sub) {
const found = users.find(u => u.id == oidcUser.sub)
if (found) {
setCurrentUser(found)
}
} else {
setCurrentUser({} as User)
}
}, [oidcUser, users])
useEffect(() => {
if (!user) return
let allGroups = new Map<string, Group>();
groups.forEach(g => {
allGroups.set(g.id!, g)
})
if (!user.auto_groups) {
user.auto_groups = []
}
let formKeyGroups = user.auto_groups.filter(g => allGroups.get(g)).map(g => allGroups.get(g)!.name)
const fUser = {
...user,
autoGroupsNames: user.auto_groups ? formKeyGroups : [],
} as FormUser
setFormUser(fUser)
form.setFieldsValue(fUser)
}, [user])
const selectValidator = (_: RuleObject, value: string[]) => {
let hasSpaceNamed = []
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()
}
const tagRender = (props: CustomTagProps) => {
const {label, value, closable, onClose} = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Tag
color="blue"
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{marginRight: 3}}
>
<strong>{value}</strong>
</Tag>
);
}
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 (
<>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{label}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</>
)
}
const dropDownRender = (menu: React.ReactElement) => (
<>
{menu}
<Divider style={{margin: '8px 0'}}/>
<Row style={{padding: '0 8px 4px'}}>
<Col flex="auto">
<span style={{color: "#9CA3AF"}}>Add new group by pressing "Enter"</span>
</Col>
<Col flex="none">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z"
fill="#9CA3AF"/>
</svg>
</Col>
</Row>
</>
)
const handleChangeTags = (value: string[]) => {
let validatedValues: string[] = []
value.forEach(function (v) {
if (v.trim().length) {
validatedValues.push(v)
}
})
setSelectedTagGroups(validatedValues)
};
const createUserToSave = (): UserToSave => {
const autoGroups = groups?.filter(g => formUser.autoGroupsNames.includes(g.name)).map(g => g.id || '') || []
// find groups that do not yet exist (newly added by the user)
const allGroupsNames: string[] = groups?.map(g => g.name);
const groupsToCreate = formUser.autoGroupsNames.filter(s => !allGroupsNames.includes(s))
return {
id: formUser.id,
email: formUser.email,
role: formUser.role,
name: formUser.name,
groupsToCreate: groupsToCreate,
auto_groups: autoGroups,
} as UserToSave
}
const showConfirmChangeRole = (userToSave: UserToSave) => {
let content = <Paragraph>With this action, you will remove the administrative privileges of your user.
Your user will be limited to read-only operations in this account. Are you sure?</Paragraph>
let contentModule = <div>{content}</div>
let name = formUser ? formUser.email : ''
confirm({
icon: <ExclamationCircleOutlined/>,
title: "Update user \"" + name + "\"",
width: 600,
content: contentModule,
okType: 'danger',
onOk() {
dispatch(userActions.saveUser.request({
getAccessTokenSilently: getTokenSilently,
payload: userToSave
}))
},
onCancel() {
},
});
}
// check if currentUser (who is doing the modification) removes the administrative privileges from themselves
const isShowConfirmWarning = (userToSave: UserToSave): boolean => {
return currentUser.id == userToSave.id && currentUser.role === "admin" && userToSave.role !== "admin"
}
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let userToSave = createUserToSave()
if (isShowConfirmWarning(userToSave)) {
showConfirmChangeRole(userToSave)
} else {
dispatch(userActions.saveUser.request({
getAccessTokenSilently: getTokenSilently,
payload: userToSave
}))
}
})
.catch((errorInfo) => {
console.log('errorInfo', errorInfo)
});
};
const onCancel = () => {
if (savedUser.loading) return
dispatch(userActions.setUser({
id: "",
email: "",
role: "",
status: "",
auto_groups: [],
name: user.name,
is_current: user.is_current,
} as User));
setFormUser({} as FormUser)
toggleEditName(false)
dispatch(userActions.setUpdateUserDrawerVisible(false));
}
const onChange = (data: any) => {
setFormUser({...formUser, ...data})
}
const changesDetected = (): boolean => {
return emailChanged() || nameChanged() || groupsChanged() || roleChanged()
}
const emailChanged = (): boolean => {
return formUser.email !== user.email
}
const roleChanged = (): boolean => {
return formUser.role !== user.role
}
const nameChanged = (): boolean => {
return formUser.name !== user.name
}
const groupsChanged = (): boolean => {
if (!formUser.autoGroupsNames) {
return false
}
if (formUser.autoGroupsNames.length != user.auto_groups.length) {
return true
}
const formGroupIds = groups?.filter(g => formUser.autoGroupsNames.includes(g.name)).map(g => g.id || '') || []
return user.auto_groups?.filter(g => !formGroupIds.includes(g)).length > 0
}
return (
<>
{user &&
<Drawer
forceRender={true}
headerStyle={{display: "none"}}
open={updateUserDrawerVisible}
bodyStyle={{paddingBottom: 80}}
onClose={onCancel}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button disabled={savedUser.loading} onClick={onCancel}>Cancel</Button>
<Button type="primary" disabled={savedUser.loading || !changesDetected()}
onClick={handleFormSubmit}>{`${formUser.id ? 'Save' : 'Invite'}`}</Button>
</Space>
}
>
<Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}
initialValues={{
["role"]: formUser.role
}}
>
<Row gutter={16}>
<Col span={24}>
<Header style={{margin: "-32px -24px 20px -24px", padding: "24px 24px 0 24px"}}>
<Row align="top">
{/*Close Icon*/}
<Col flex="none" style={{display: "flex"}}>
{!editName && user.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>
{/* Name Label*/}
<Col flex="auto">
{!editName && user.id && formUser.name !== "" ? (
<div className={"access-control input-text ant-drawer-title"}
onClick={() => toggleEditName(true)}>{formUser.name ? formUser.name : formUser.name}
<EditOutlined/></div>
) : (
<Form.Item
name="name"
label="Name"
rules={[{
required: false,
message: 'Please add a new name for this user',
whitespace: true
}]}
>
<Input
placeholder={formUser.name}
ref={inputNameRef}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)}
autoComplete="off"/>
</Form.Item>)}
</Col>
</Row>
</Header>
</Col>
<Col span={24}>
<Form.Item
name="email"
label="Email"
>
<Input
disabled={user.id}
value={formUser.email}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="role"
label="Role"
>
<Select
style={{width: '100%'}}
disabled={(currentUser.role != null && currentUser.role !== "admin") || formUser.is_current}>
<Option value="admin">admin</Option>
<Option value="user">user</Option>
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="autoGroupsNames"
label="Auto-assigned groups"
tooltip="Every peer enrolled with this user will be automatically added to these groups"
rules={[{validator: selectValidator}]}
>
<Select mode="tags"
style={{width: '100%'}}
placeholder="Associate groups with the user"
tagRender={tagRender}
onChange={handleChangeTags}
disabled={currentUser.role != null && currentUser.role !== "admin"}
dropdownRender={dropDownRender}
>
{
tagGroups.map(m =>
<Option key={m}>{optionRender(m)}</Option>
)
}
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Divider></Divider>
</Col>
{currentUser && currentUser.role !== "admin" && (
<div>
<Col span={24}>
<Alert
message={<div style={{color: "#5a5c5a"}}>
You are not an administrator, therefore you can't update users.</div>}
showIcon={false}
type="warning"/>
</Col>
<br></br>
</div>
)}
</Row>
</Form>
</Drawer>
}
</>
)
}
export default UserUpdate
+222
View File
@@ -0,0 +1,222 @@
import {useGetTokenSilently} from "../../utils/token";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {Button, Col, Divider, Form, Input, InputNumber, message, Modal, Row, Space, Typography} from "antd";
import {Container} from "../Container";
import {CheckOutlined, CopyOutlined, QuestionCircleFilled} from "@ant-design/icons";
import SyntaxHighlighter from "react-syntax-highlighter";
import React, {useEffect, useRef, useState} from "react";
import {actions as personalAccessTokenActions} from "../../store/personal-access-token";
import {PersonalAccessTokenCreate} from "../../store/personal-access-token/types";
import {copyToClipboard} from "../../utils/common";
const {Title, Text, Paragraph} = Typography;
const ExpiresInDefault = 30
const styleNotification = {marginTop: 85}
const AddPATPopup = () => {
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const user = useSelector((state: RootState) => state.user.user)
const addTokenModalOpen = useSelector((state: RootState) => state.personalAccessToken.newPersonalAccessTokenPopupVisible)
const [confirmModal, confirmModalContextHolder] = Modal.useModal();
const [showPlainToken, setShowPlainToken] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
const [plainToken, setPlainToken] = useState("")
const inputNameRef = useRef<any>(null)
const [form] = Form.useForm()
const savedPersonalAccessToken = useSelector((state: RootState) => state.personalAccessToken.savedPersonalAccessToken);
const onCopyClick = (text: string, copied: boolean) => {
copyToClipboard(text)
setTokenCopied(true)
if (copied) {
setTimeout(() => {
onCopyClick(text, false)
}, 2000)
}
}
const onCancel = () => {
setShowPlainToken(false)
setTokenCopied(false)
if (savedPersonalAccessToken.loading) return
dispatch(personalAccessTokenActions.setPersonalAccessToken({
user_id: "",
name: "",
expires_in: 7
} as PersonalAccessTokenCreate))
form.resetFields()
dispatch(personalAccessTokenActions.setNewPersonalAccessTokenPopupVisible(false));
dispatch(personalAccessTokenActions.setSavedPersonalAccessToken({...savedPersonalAccessToken, success: false}));
dispatch(personalAccessTokenActions.resetSavedPersonalAccessToken(null))
}
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let personalAccessTokenToSave = {
user_id: user.id,
name: values.name,
expires_in: values.expires_in,
} as PersonalAccessTokenCreate
dispatch(personalAccessTokenActions.savePersonalAccessToken.request({
getAccessTokenSilently: getTokenSilently,
payload: personalAccessTokenToSave
}))
})
.catch((errorInfo) => {
console.log('errorInfo', errorInfo)
});
};
const createKey = 'saving';
useEffect(() => {
if (savedPersonalAccessToken.loading) {
message.loading({content: 'Saving...', key: createKey, duration: 0, style: styleNotification});
} else if (savedPersonalAccessToken.success) {
message.destroy(createKey)
setPlainToken(savedPersonalAccessToken.data.plain_token)
setShowPlainToken(true)
form.resetFields()
} else if (savedPersonalAccessToken.error) {
message.error({
content: 'Failed to create personal access token. You might not have enough permissions.',
key: createKey,
duration: 2,
style: styleNotification
});
dispatch(personalAccessTokenActions.setNewPersonalAccessTokenPopupVisible(false));
setShowPlainToken(false)
setTokenCopied(false)
dispatch(personalAccessTokenActions.setSavedPersonalAccessToken({...savedPersonalAccessToken, error: null}));
dispatch(personalAccessTokenActions.resetSavedPersonalAccessToken(null))
}
}, [savedPersonalAccessToken])
useEffect(() => {
const keyDownHandler = (event: any) => {
console.log('User pressed: ', event.key);
if (event.key === 'Enter') {
event.preventDefault();
handleFormSubmit();
}
};
document.addEventListener('keydown', keyDownHandler);
return () => {
document.removeEventListener('keydown', keyDownHandler);
};
}, []);
return (
<>
<Modal
open={addTokenModalOpen}
onCancel={onCancel}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
{!showPlainToken && <Button disabled={savedPersonalAccessToken.loading} onClick={onCancel}>{"Cancel"}</Button>}
{!showPlainToken && <Button type="primary" disabled={showPlainToken}
onClick={handleFormSubmit}>{"Create token"}</Button>}
{showPlainToken && <Button type="primary" disabled={!showPlainToken} onClick={onCancel}>Done</Button>}
</Space>
}
width={460}
>
<Container style={{textAlign: "start"}}>
<Paragraph
style={{textAlign: "start", whiteSpace: "pre-line", fontSize: "22px", fontWeight: "500"}}>
{showPlainToken ? "Token created successfully!" : "Create a Personal Access Token"}
</Paragraph>
{!showPlainToken && <Paragraph type={"secondary"}
style={{
textAlign: "start",
fontSize: "14px",
whiteSpace: "pre-line",
marginTop: "-23px",
paddingBottom: "25px",
}}>
{"This token can be used to authenticate against" + "\n" + "NetBird's Public API."}
</Paragraph>}
{showPlainToken && <Paragraph type={"secondary"} style={{
textAlign: "start",
fontSize: "14px",
whiteSpace: "pre-line",
marginTop: "40px",
}}>{"This token will not be shown again, so be sure to copy it and" + "\n" + "store in a secure location."}</Paragraph>}
{!showPlainToken && <Form layout="vertical" hideRequiredMark form={form}
initialValues={{
expires_in: ExpiresInDefault,
}}
>
<Row gutter={16}>
<Col span={24}>
<Row align="top">
<Col flex="auto">
<Paragraph style={{fontSize: "16px", fontWeight: "500", marginTop: "-8px"}}>Token name</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Create a name to identify the token easily</Paragraph>
<Form.Item
name="name"
style={{marginTop: "-10px"}}
rules={[{
required: true,
message: 'Please add a name for this personal access token',
whitespace: true
}]}
>
<Input
placeholder={"for example \"Infra token\""}
ref={inputNameRef}
autoComplete="off"/>
</Form.Item>
</Col>
</Row>
</Col>
<Col span={24} style={{textAlign: "left"}}>
<Paragraph style={{fontSize: "16px", fontWeight: "500"}}>Expires in</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Number of days this token is valid for</Paragraph>
<Form.Item
name="expires_in"
style={{marginTop: "-10px"}}
rules={[{
type: 'number',
min: 1,
max: 365,
message: 'The expiration should be set between 1 and 365 days'
}]}>
<InputNumber addonAfter=" Days" style={{maxWidth: "150px"}}/>
</Form.Item>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Should be between 1 and 365 days</Paragraph>
</Col>
{/*<Col span={24}>*/}
{/* <Button icon={<QuestionCircleFilled/>} type="link" target="_blank" disabled={true} style={{marginTop: "20px", marginBottom: "20px"}}*/}
{/* href="https://netbird.io/docs/overview/personal-access-tokens">Learn more about personal access tokens</Button>*/}
{/*</Col>*/}
</Row>
</Form>}
{showPlainToken &&
<Input style={{marginTop: "-15px", marginBottom: "25px"}} suffix={
!tokenCopied ? <Button type="text" size="middle" className="btn-copy-code" icon={<CopyOutlined/>}
style={{color: "rgb(107, 114, 128)", marginTop: "-1px"}}
onClick={() => onCopyClick(plainToken, true)}/>
: <Button type="text" size="middle" className="btn-copy-code" icon={<CheckOutlined/>}
style={{color: "green", marginTop: "-1px"}}/>
}
defaultValue={plainToken}
readOnly={true}
></Input>}
</Container>
</Modal>
{confirmModalContextHolder}
</>
)
}
export default AddPATPopup
@@ -0,0 +1,292 @@
import {
Button,
Col,
Divider,
Form,
Input,
Modal,
Row,
Select,
Space,
Tag,
Typography
} from "antd";
import {Container} from "../Container";
import {CloseOutlined} from "@ant-design/icons";
import React, {useEffect, useRef, useState} from "react";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {useGetTokenSilently} from "../../utils/token";
import {actions as userActions} from "../../store/user";
import {actions as groupActions} from "../../store/group";
import {User, UserToSave} from "../../store/user/types";
import {Header} from "antd/es/layout/layout";
import {RuleObject} from "antd/lib/form";
import {CustomTagProps} from "rc-select/lib/BaseSelect";
const {Title, Text, Paragraph} = Typography;
const {Option} = Select;
const AddServiceUserPopup = () => {
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const groups = useSelector((state: RootState) => state.group.data)
const users = useSelector((state: RootState) => state.user.data)
const user = useSelector((state: RootState) => state.user.user)
const failed = useSelector((state: RootState) => state.user.failed);
const loading = useSelector((state: RootState) => state.user.loading);
const addServiceUserModalOpen = useSelector((state: RootState) => state.user.addServiceUserPopupVisible)
const savedUser = useSelector((state: RootState) => state.user.savedUser)
const [confirmModal, confirmModalContextHolder] = Modal.useModal();
const [form] = Form.useForm()
const inputNameRef = useRef<any>(null)
const [tagGroups, setTagGroups] = useState([] as string[])
const [selectedTagGroups, setSelectedTagGroups] = useState([] as string[])
const createUserToSave = (values: any): UserToSave => {
const autoGroups = groups?.filter(g => values.autoGroupsNames && values.autoGroupsNames.includes(g.name)).map(g => g.id || '') || []
// find groups that do not yet exist (newly added by the user)
const allGroupsNames: string[] = groups?.map(g => g.name);
const groupsToCreate = values.autoGroupsNames?.filter((s: string) => !allGroupsNames.includes(s)) || []
return {
id: values.id,
role: values.role,
name: values.name,
groupsToCreate: groupsToCreate,
auto_groups: autoGroups,
is_service_user: true
} as UserToSave
}
const onCancel = () => {
if (savedUser.loading) return
dispatch(userActions.setUser(null as unknown as User));
form.resetFields();
dispatch(userActions.setAddServiceUserPopupVisible(false));
}
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let userToSave = createUserToSave(values)
dispatch(userActions.saveUser.request({
getAccessTokenSilently: getTokenSilently,
payload: userToSave
}))
form.resetFields();
dispatch(userActions.getServiceUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(userActions.setAddServiceUserPopupVisible(false));
})
.catch((errorInfo) => {
console.log('errorInfo', errorInfo)
});
};
const selectValidator = (_: RuleObject, value: string[]) => {
let hasSpaceNamed = []
if (!value) {
return Promise.resolve()
}
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()
}
const tagRender = (props: CustomTagProps) => {
const {label, value, closable, onClose} = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Tag
color="blue"
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{marginRight: 3}}
>
<strong>{value}</strong>
</Tag>
);
}
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}
<Divider style={{margin: '8px 0'}}/>
<Row style={{padding: '0 8px 4px'}}>
<Col flex="auto">
<span style={{color: "#9CA3AF"}}>Add new group by pressing "Enter"</span>
</Col>
<Col flex="none">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z"
fill="#9CA3AF"/>
</svg>
</Col>
</Row>
</>
)
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 (
<>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{label}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</>
)
}
useEffect(() => {
setTagGroups(groups?.filter(g => g.name != "All").map(g => g.name) || [])
}, [groups])
useEffect(() => {
dispatch(groupActions.getGroups.request({
getAccessTokenSilently: getTokenSilently,
payload: null
}))
}, [])
return (
<>
<Modal
open={addServiceUserModalOpen}
onCancel={onCancel}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button disabled={loading} onClick={onCancel}>Cancel</Button>
<Button type="primary"
onClick={handleFormSubmit}>Create user</Button>
</Space>
}
width={460}
>
<Container style={{textAlign: "start"}}>
<Paragraph
style={{textAlign: "start", whiteSpace: "pre-line", fontSize: "22px", fontWeight: "500"}}>
{"Add Service User"}
</Paragraph>
<Paragraph type={"secondary"}
style={{
textAlign: "start",
whiteSpace: "pre-line",
marginTop: "-23px",
paddingBottom: "25px",
}}>
{"Service Users are non-login users that are not associated with any specific person."}
</Paragraph>
<Form layout="vertical" hideRequiredMark form={form}
initialValues={{
["role"]: "user"
}}
>
<Row gutter={16}>
<Col span={24}>
<Paragraph style={{fontSize: "16px", fontWeight: "500", marginTop: "-10px"}}>Name</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Set a name to easily identify the user</Paragraph>
<Form.Item
name="name"
rules={[{
required: true,
message: 'Please add a new name for this user',
whitespace: true
}]}
style={{marginTop: "-8px"}}
>
<Input
placeholder={'for example "Ansible User"'}
ref={inputNameRef}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={24}>
<Paragraph style={{fontSize: "16px", fontWeight: "500", marginTop: "0px"}}>Role</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Set a role for the user to assign dashboard access permissions</Paragraph>
<Form.Item
name="role"
rules={[{
required: true,
message: 'Please select a role for this user',
whitespace: true
}]}
style={{marginTop: "-8px"}}
>
<Select style={{width: "120px"}}>
<Option value="admin">admin</Option>
<Option value="user">user</Option>
</Select>
</Form.Item>
</Col>
{/*<Col span={24}>*/}
{/* <Paragraph style={{fontSize: "16px", fontWeight: "500", marginTop: "0px"}}>Auto-assigned groups</Paragraph>*/}
{/* <Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Add groups, that will be assigned to peers added by this user</Paragraph>*/}
{/* <Form.Item*/}
{/* name="autoGroupsNames"*/}
{/* label="Auto-assigned groups"*/}
{/* tooltip="Every peer enrolled with this user will be automatically added to these groups"*/}
{/* rules={[{validator: selectValidator}]}*/}
{/* >*/}
{/* <Select mode="tags"*/}
{/* style={{width: '100%'}}*/}
{/* placeholder="Associate groups with the user"*/}
{/* tagRender={tagRender}*/}
{/* onChange={handleChangeTags}*/}
{/* dropdownRender={dropDownRender}*/}
{/* >*/}
{/* {*/}
{/* tagGroups.map(m =>*/}
{/* <Option key={m}>{optionRender(m)}</Option>*/}
{/* )*/}
{/* }*/}
{/* </Select>*/}
{/* </Form.Item>*/}
{/*</Col>*/}
</Row>
</Form>
</Container>
</Modal>
{confirmModalContextHolder}
</>
)
}
export default AddServiceUserPopup
+317
View File
@@ -0,0 +1,317 @@
import {
Button,
Col,
Divider,
Form,
Input,
Modal,
Row,
Select,
Space,
Tag,
Typography
} from "antd";
import {Container} from "../Container";
import {CloseOutlined} from "@ant-design/icons";
import React, {useEffect, useRef, useState} from "react";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {useGetTokenSilently} from "../../utils/token";
import {actions as userActions} from "../../store/user";
import {actions as groupActions} from "../../store/group";
import {User, UserToSave} from "../../store/user/types";
import {Header} from "antd/es/layout/layout";
import {RuleObject} from "antd/lib/form";
import {CustomTagProps} from "rc-select/lib/BaseSelect";
const {Title, Text, Paragraph} = Typography;
const {Option} = Select;
const InviteUserPopup = () => {
const {getTokenSilently} = useGetTokenSilently()
const dispatch = useDispatch()
const groups = useSelector((state: RootState) => state.group.data)
const users = useSelector((state: RootState) => state.user.data)
const user = useSelector((state: RootState) => state.user.user)
const failed = useSelector((state: RootState) => state.user.failed);
const loading = useSelector((state: RootState) => state.user.loading);
const inviteUserModalOpen = useSelector((state: RootState) => state.user.inviteUserPopupVisible)
const savedUser = useSelector((state: RootState) => state.user.savedUser)
const [confirmModal, confirmModalContextHolder] = Modal.useModal();
const [form] = Form.useForm()
const inputNameRef = useRef<any>(null)
const [tagGroups, setTagGroups] = useState([] as string[])
const [selectedTagGroups, setSelectedTagGroups] = useState([] as string[])
const createUserToSave = (values: any): UserToSave => {
const autoGroups = groups?.filter(g => values.autoGroupsNames && values.autoGroupsNames.includes(g.name)).map(g => g.id || '') || []
// find groups that do not yet exist (newly added by the user)
const allGroupsNames: string[] = groups?.map(g => g.name);
const groupsToCreate = values.autoGroupsNames?.filter((s: string) => !allGroupsNames.includes(s)) || []
return {
id: values.id,
role: values.role,
email: values.email,
name: values.name,
groupsToCreate: groupsToCreate,
auto_groups: autoGroups,
is_service_user: false
} as UserToSave
}
const onCancel = () => {
if (savedUser.loading) return
dispatch(userActions.setUser(null as unknown as User));
form.resetFields();
dispatch(userActions.setInviteUserPopupVisible(false));
}
const handleFormSubmit = () => {
form.validateFields()
.then((values) => {
let userToSave = createUserToSave(values)
dispatch(userActions.saveUser.request({
getAccessTokenSilently: getTokenSilently,
payload: userToSave
}))
form.resetFields();
dispatch(userActions.getRegularUsers.request({getAccessTokenSilently: getTokenSilently, payload: null}));
dispatch(userActions.setInviteUserPopupVisible(false));
})
.catch((errorInfo) => {
console.log('errorInfo', errorInfo)
});
};
const selectValidator = (_: RuleObject, value: string[]) => {
let hasSpaceNamed = []
if (!value) {
return Promise.resolve()
}
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()
}
const tagRender = (props: CustomTagProps) => {
const {label, value, closable, onClose} = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Tag
color="blue"
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{marginRight: 3}}
>
<strong>{value}</strong>
</Tag>
);
}
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}
<Divider style={{margin: '8px 0'}}/>
<Row style={{padding: '0 8px 4px'}}>
<Col flex="auto">
<span style={{color: "#9CA3AF"}}>Add new group by pressing "Enter"</span>
</Col>
<Col flex="none">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.70455 7.19176V5.89915H10.3949C10.7727 5.89915 11.1174 5.80634 11.429 5.62074C11.7405 5.43513 11.9875 5.18655 12.1697 4.875C12.3554 4.56345 12.4482 4.21875 12.4482 3.84091C12.4482 3.46307 12.3554 3.12003 12.1697 2.81179C11.9841 2.50024 11.7356 2.25166 11.424 2.06605C11.1158 1.88044 10.7727 1.78764 10.3949 1.78764H9.83807V0.5H10.3949C11.0114 0.5 11.5715 0.650805 12.0753 0.952414C12.5791 1.25402 12.9818 1.65672 13.2834 2.16051C13.585 2.6643 13.7358 3.22443 13.7358 3.84091C13.7358 4.30161 13.648 4.73414 13.4723 5.13849C13.3 5.54285 13.0613 5.89915 12.7564 6.20739C12.4515 6.51562 12.0968 6.75758 11.6925 6.93324C11.2881 7.10559 10.8556 7.19176 10.3949 7.19176H1.70455ZM4.90128 11.0646L0.382102 6.54545L4.90128 2.02628L5.79119 2.91619L2.15696 6.54545L5.79119 10.1747L4.90128 11.0646Z"
fill="#9CA3AF"/>
</svg>
</Col>
</Row>
</>
)
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 (
<>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{label}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</>
)
}
useEffect(() => {
setTagGroups(groups?.filter(g => g.name != "All").map(g => g.name) || [])
}, [groups])
useEffect(() => {
dispatch(groupActions.getGroups.request({
getAccessTokenSilently: getTokenSilently,
payload: null
}))
}, [])
return (
<>
<Modal
open={inviteUserModalOpen}
onCancel={onCancel}
footer={
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button disabled={loading} onClick={onCancel}>Cancel</Button>
<Button type="primary"
onClick={handleFormSubmit}>Invite</Button>
</Space>
}
width={460}
>
<Container style={{textAlign: "start"}}>
<Paragraph
style={{textAlign: "start", whiteSpace: "pre-line", fontSize: "22px", fontWeight: "500"}}>
{"Invite User"}
</Paragraph>
<Paragraph type={"secondary"}
style={{
textAlign: "start",
whiteSpace: "pre-line",
fontSize: "14px",
marginTop: "-23px",
paddingBottom: "25px",
}}>
{"Invite a user to your network and set their permissions."}
</Paragraph>
<Form layout="vertical" hideRequiredMark form={form}
initialValues={{
["role"]: "user"
}}
>
<Row gutter={16}>
<Col span={24}>
<Paragraph style={{fontSize: "16px", fontWeight: "500", marginTop: "-10px"}}>Name</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Set a name to easily identify the user</Paragraph>
<Form.Item
name="name"
rules={[{
required: true,
message: 'Please add a name for this user',
whitespace: true
}]}
style={{marginTop: "-8px"}}
>
<Input
placeholder={'for example "Max Schmidt"'}
ref={inputNameRef}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={24}>
<Paragraph style={{fontSize: "16px", fontWeight: "500", marginTop: "0px"}}>Email</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Provide the email address of the user</Paragraph>
<Form.Item
name="email"
rules={[{
required: true,
message: 'Please add a valid email address for this user',
whitespace: false,
pattern: new RegExp(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i)
}]}
style={{marginTop: "-8px"}}
>
<Input
placeholder={'for example "max.schmidt@gmail.com"'}
ref={inputNameRef}
autoComplete="off"/>
</Form.Item>
</Col>
<Col span={24}>
<Paragraph style={{fontSize: "16px", fontWeight: "500", marginTop: "0px"}}>Role</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Set a role for the user to assign dashboard access permissions</Paragraph>
<Form.Item
name="role"
rules={[{
required: true,
message: 'Please select a role for this user',
whitespace: true
}]}
style={{marginTop: "-8px"}}
>
<Select style={{width: "120px"}}>
<Option value="admin">admin</Option>
<Option value="user">user</Option>
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Paragraph style={{fontSize: "16px", fontWeight: "500", marginTop: "0px"}}>Auto-assigned groups</Paragraph>
<Paragraph type={"secondary"} style={{fontSize: "14px", marginTop: "-18px"}}>Add groups, that will be assigned to peers added by this user</Paragraph>
<Form.Item
name="autoGroupsNames"
tooltip="Every peer enrolled with this user will be automatically added to these groups"
rules={[{validator: selectValidator}]}
style={{marginTop: "-8px"}}
>
<Select mode="tags"
style={{width: '100%'}}
placeholder="Associate groups with the user"
tagRender={tagRender}
onChange={handleChangeTags}
dropdownRender={dropDownRender}
>
{
tagGroups.map(m =>
<Option key={m}>{optionRender(m)}</Option>
)
}
</Select>
</Form.Item>
</Col>
{/*<Col span={24}>*/}
{/* <Button icon={<QuestionCircleFilled/>} type="link" target="_blank" disabled={true} style={{marginTop: "20px", marginBottom: "20px"}}*/}
{/* href="https://netbird.io/docs/overview/personal-access-tokens">Learn more about user</Button>*/}
{/*</Col>*/}
</Row>
</Form>
</Container>
</Modal>
{confirmModalContextHolder}
</>
)
}
export default InviteUserPopup
@@ -2,9 +2,9 @@ import React, {useState} from 'react';
import {Tabs, TabsProps} from "antd";
import Icon, {AndroidFilled, AppleFilled, WindowsFilled} from "@ant-design/icons";
import {ReactComponent as LinuxSVG} from "../icons/terminal_icon.svg";
import {ReactComponent as LinuxSVG} from "../../../icons/terminal_icon.svg";
import UbuntuTab from "./UbuntuTab";
import {ReactComponent as DockerSVG} from "../icons/docker_icon.svg";
import {ReactComponent as DockerSVG} from "../../../icons/docker_icon.svg";
import Paragraph from "antd/lib/typography/Paragraph";
import WindowsTab from "./WindowsTab";
import MacTab from "./MacTab";
@@ -7,7 +7,7 @@ import {
Space,
Steps, Button, Popover, StepsProps
} from "antd";
import {copyToClipboard} from "../../utils/common";
import {copyToClipboard} from "../../../../utils/common";
import {CheckOutlined, CopyOutlined} from "@ant-design/icons";
import React, {useEffect, useState} from "react";
const { Step } = Steps;
@@ -1,4 +1,4 @@
import {getConfig} from "../../config";
import {getConfig} from "../../../../config";
const { grpcApiOrigin } = getConfig();
+8
View File
@@ -141,6 +141,14 @@ body {
border: none;
}
td.non-highlighted-table-column {
background-color: #FFFFFF !important;
}
.ant-table-tbody > tr.ant-table-row:hover > td {
background: #FAFAFA !important;
}
.ant-table-thead .ant-table-cell {
font-weight: normal !important;
}
+3 -1
View File
@@ -20,8 +20,10 @@ async function apiRequest<T>(params: ApiRequestParams): Promise<ApiResponse<T>>
statusCode: -1
};
let queryParams = (params.data as any).queryParams ? (params.data as any).queryParams : {};
try {
response = await axios.request({url, data, method: params.method, headers: builtHeader as any});
response = await axios.request({url, data, method: params.method, headers: builtHeader as any, params: queryParams});
} catch (err: any) {
error = <ApiError>{
code: err ? err.code : '-1',
Loaded 20 of 42 files, more files were not shown because too many files have changed in this diff. Show more