Support User Invites (#86)

This PR brings an "Invite User" button to the Users view
and a view to creating (inviting) a new user to the account.
This function calls the Management API POST /users
endpoint that creates a new user.
This commit is contained in:
Misha Bragin
2022-10-13 18:01:32 +02:00
committed by GitHub
parent 17e671200e
commit 18cfddbbe7
10 changed files with 649 additions and 118 deletions

View File

@@ -1,27 +1,32 @@
import React, {useEffect, useState} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import {Provider} from "react-redux";
import {Redirect, Route, Switch} from 'react-router-dom';
import Navbar from './components/Navbar';
import Peers from './views/Peers';
import FooterComponent from './components/FooterComponent';
import SetupKeys from "./views/SetupKeys";
import AddPeer from "./views/AddPeer";
import Users from './views/Users';
import AccessControl from './views/AccessControl';
import Routes from './views/Routes';
import Banner from "./components/Banner";
import {store} from "./store";
import { Col, Layout, Row} from 'antd';
import {Container} from "./components/Container";
import {withOidcSecure} from '@axa-fr/react-oidc';
import { hotjar } from 'react-hotjar';
import {apiClient, store} from "./store";
import {hotjar} from 'react-hotjar';
import {getConfig} from "./config";
import Banner from "./components/Banner";
import {Col, Layout, Row} from "antd";
import {Container} from "./components/Container";
import Navbar from "./components/Navbar";
import {Redirect, Route, Switch} from "react-router-dom";
import {withOidcSecure} from "@axa-fr/react-oidc";
import Peers from "./views/Peers";
import Routes from "./views/Routes";
import AddPeer from "./views/AddPeer";
import SetupKeys from "./views/SetupKeys";
import AccessControl from "./views/AccessControl";
import Users from "./views/Users";
import FooterComponent from "./components/FooterComponent";
import {useGetAccessTokenSilently} from "./utils/token";
import {User} from "./store/user/types";
import Loading, {SecureLoading} from "./components/Loading";
const {Header, Content} = Layout;
function App() {
const { hotjarTrackID } = getConfig();
const run = useRef(false)
const [show, setShow] = useState(false)
const {getAccessTokenSilently} = useGetAccessTokenSilently();
const {hotjarTrackID} = getConfig();
// @ts-ignore
if (hotjarTrackID && window._DATADOG_SYNTHETICS_BROWSER === undefined) {
hotjar.initialize(hotjarTrackID, 6);
@@ -42,10 +47,28 @@ function App() {
return () => {
window.removeEventListener('resize', hideMenu);
};
});
}, []);
useEffect(() => {
if (!run.current) {
run.current = true
apiClient.request<User[]>('GET', `/api/users`, {getAccessTokenSilently: getAccessTokenSilently})
.then(() => {
setShow(true)
})
.catch(e => {
setShow(true)
console.log(e)
})
}
}, [getAccessTokenSilently])
return (
<Provider store={store}>
<>
<Provider store={store}>
{!show && <SecureLoading padding="3em" width={50} height={50}/>}
{show &&
<Layout>
<Banner/>
<Header className="header" style={{
@@ -62,7 +85,7 @@ function App() {
</Col>
</Row>
</Header>
<Content style={{ minHeight: "100vh"}}>
<Content style={{minHeight: "100vh"}}>
<Switch>
<Route
exact
@@ -83,8 +106,10 @@ function App() {
</Content>
<FooterComponent/>
</Layout>
</Provider>
);
}
</Provider>
</>
)
}
export default App;

View File

@@ -1,17 +1,32 @@
import React from "react";
import loading from "../assets/bars.svg";
import {Space} from "antd";
import {OidcSecure} from "@axa-fr/react-oidc";
type Props = {
type LoadingProps = {
padding?: string;
width?: string;
height?: string;
width?: number;
height?: number;
};
const Loading:React.FC<Props> = ({padding, width, height}) => (
<Space direction="vertical" align="center" style={{display: 'flex', padding: `${padding || `.25em`}`}}>
<img src={loading} alt="Loading" style={{width: `${width || '25px'}`, height: `${height || '25px'}`}}/>
const Loading: React.FC<LoadingProps> = ({padding, width, height}) => (
<Space direction="vertical" align="center" style={{
marginTop: `-${height ? (height / 2) + "px" : '-25px'}`,
marginLeft: `-${width ? (width / 2) + "px" : '-25px'}`,
position: "absolute",
top: "50%",
left: "50%",
display: 'flex'
}}>
<img src={loading} alt="Loading"
style={{width: `${width ? width + "px" : '25px'}`, height: `${height ? height + "px" : '25px'}`}}/>
</Space>
);
export default Loading;
// Wrapper of Loading to handle cases when it is shown within the authenticated layout and has to trigger authentication when token expires.
export const SecureLoading = (props: LoadingProps) => (
<OidcSecure>
<Loading {...props} />
</OidcSecure>
);

View File

@@ -2,6 +2,7 @@ import {OidcUserStatus, useOidc, useOidcUser} from "@axa-fr/react-oidc";
import {Button, Result} from "antd";
import React from "react";
import {getConfig} from "../config";
import {ResultStatusType} from "antd/lib/result";
function LoginError() {
const { logout } = useOidc();
@@ -11,9 +12,18 @@ function LoginError() {
const urlParams = new URLSearchParams(queryString);
if (urlParams.get("error") === "access_denied") {
let title = urlParams.get("error_description")
let status :ResultStatusType = "warning"
// this comes from the auth0 rule that links accounts
if (title === "account linked successfully") {
status = "success"
title = "Your account has been linked successfully. Please log in again to complete the setup."
}
return <Result
status="warning"
title={urlParams.get("error_description")}
status={status}
title={title}
extra={<>
<a href={window.location.origin}>
<Button type="primary">

View File

@@ -0,0 +1,377 @@
import React, {useEffect, 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, 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 {useGetAccessTokenSilently} from "../utils/token";
import {useOidcUser} from "@axa-fr/react-oidc";
const {Paragraph, Text} = Typography;
const {confirm} = Modal;
const {Option} = Select;
const UserInvite = () => {
const {oidcUser} = useOidcUser();
const {getAccessTokenSilently} = useGetAccessTokenSilently()
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 [formUser, setFormUser] = useState({} as FormUser)
const [currentUser, setCurrentUser] = useState({} as User)
const [form] = Form.useForm()
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 only 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: getAccessTokenSilently,
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: getAccessTokenSilently,
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
} as User));
setFormUser({} as FormUser)
dispatch(userActions.setUpdateUserDrawerVisible(false));
}
const onChange = (data: any) => {
setFormUser({...formUser, ...data})
}
const changesDetected = (): boolean => {
return groupsChanged() || roleChanged()
}
const roleChanged = (): boolean => {
return formUser.role !== user.role
}
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}>Save</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"}}>
{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">
<div className={"ant-drawer-title"}>
{formUser.name}</div>
</Col>
</Row>
</Header>
</Col>
<Col span={24}>
<Form.Item
name="email"
label="Email"
>
<Input
disabled={true}
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"}>
<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 key"
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>
{/*<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
href="https://docs.netbird.io/docs/overview/setup-keys"
style={{color: 'rgb(07, 114, 128)'}}>Learn
more about setup keys</Button>*/}
</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 UserInvite

View File

@@ -1,8 +1,8 @@
import React, {useEffect, useState} from 'react';
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, ExclamationCircleOutlined} from "@ant-design/icons";
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";
@@ -29,11 +29,23 @@ const UserUpdate = () => {
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])
@@ -169,7 +181,7 @@ const UserUpdate = () => {
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 only in this account. Are you sure?</Paragraph>
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 : ''
@@ -219,6 +231,7 @@ const UserUpdate = () => {
id: "",
email: "",
role: "",
status: "",
auto_groups: [],
name: user.name
} as User));
@@ -231,13 +244,17 @@ const UserUpdate = () => {
}
const changesDetected = (): boolean => {
return groupsChanged() || roleChanged()
return formUser.email !== "" && (nameChanged() || groupsChanged() || roleChanged())
}
const roleChanged = (): boolean => {
return formUser.role !== user.role
}
const nameChanged = (): boolean => {
return formUser.name !== user.name
}
const groupsChanged = (): boolean => {
if (!formUser.autoGroupsNames) {
return false
@@ -263,7 +280,7 @@ const UserUpdate = () => {
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button disabled={savedUser.loading} onClick={onCancel}>Cancel</Button>
<Button type="primary" disabled={savedUser.loading || !changesDetected()}
onClick={handleFormSubmit}>Save</Button>
onClick={handleFormSubmit}>{`${formUser.id ? 'Save' : 'Invite'}`}</Button>
</Space>
}
>
@@ -278,7 +295,7 @@ const UserUpdate = () => {
<Row align="top">
{/*Close Icon*/}
<Col flex="none" style={{display: "flex"}}>
{user.id &&
{!editName && user.id &&
<button type="button" aria-label="Close" className="ant-drawer-close"
style={{paddingTop: 3}}
onClick={onCancel}>
@@ -291,8 +308,27 @@ const UserUpdate = () => {
</Col>
{/* Name Label*/}
<Col flex="auto">
<div className={"ant-drawer-title"}>
{formUser.name}</div>
{!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: true,
message: 'Please add a new name for this peer',
whitespace: true
}]}
>
<Input
placeholder={formUser.name}
ref={inputNameRef}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)}
autoComplete="off"/>
</Form.Item>)}
</Col>
</Row>
</Header>
@@ -303,7 +339,7 @@ const UserUpdate = () => {
label="Email"
>
<Input
disabled={true}
disabled={user.id}
value={formUser.email}
style={{color: "#5a5c5a"}}
autoComplete="off"/>
@@ -347,10 +383,6 @@ const UserUpdate = () => {
</Col>
<Col span={24}>
<Divider></Divider>
{/*<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
href="https://docs.netbird.io/docs/overview/setup-keys"
style={{color: 'rgb(07, 114, 128)'}}>Learn
more about setup keys</Button>*/}
</Col>
{currentUser && currentUser.role !== "admin" && (
<div>

View File

@@ -41,7 +41,7 @@ const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const loadingComponent = () => <Loading padding="3em" width="50px" height="50px"/>
const loadingComponent = () => <Loading padding="3em" width={50} height={50}/>
root.render(
<OidcProvider

View File

@@ -8,82 +8,96 @@ import {Group} from "../group/types";
import {actions as groupActions} from "../group";
export function* getUsers(action: ReturnType<typeof actions.getUsers.request>): Generator {
try {
const effect = yield call(service.getUsers, action.payload);
const response = effect as ApiResponse<User[]>;
try {
const effect = yield call(service.getUsers, action.payload);
const response = effect as ApiResponse<User[]>;
yield put(actions.getUsers.success(response.body));
} catch (err) {
yield put(actions.getUsers.failure(err as ApiError));
}
yield put(actions.getUsers.success(response.body));
} catch (err) {
yield put(actions.getUsers.failure(err as ApiError));
}
}
export function* saveUser(action: ReturnType<typeof actions.saveUser.request>): Generator {
try {
yield put(actions.setSavedUser({
loading: true,
success: false,
failure: false,
error: null,
data: null
} as CreateResponse<User | null>))
try {
yield put(actions.setSavedUser({
loading: true,
success: false,
failure: false,
error: null,
data: null
} as CreateResponse<User | null>))
const userToSave = action.payload.payload
const userToSave = action.payload.payload
let groupsToCreate = userToSave.groupsToCreate
if (!groupsToCreate) {
groupsToCreate = []
let groupsToCreate = userToSave.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<Group>[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id)
const newGroups = [...userToSave.auto_groups, ...resGroups]
let payload = {
name: userToSave.name,
email: userToSave.email,
role: userToSave.role,
auto_groups: newGroups,
} as UserToSave
let effect
if (!userToSave.id) {
console.log("creating user:" + payload)
effect = yield call(service.createUser, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: payload
});
} else {
payload.id = userToSave.id
effect = yield call(service.editUser, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: payload
});
}
const response = effect as ApiResponse<User>;
yield put(actions.saveUser.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as CreateResponse<User | null>));
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.getUsers.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
} catch (err) {
yield put(actions.saveUser.failure({
loading: false,
success: false,
failure: false,
error: err as ApiError,
data: null
} as CreateResponse<User | null>));
}
// 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<Group>[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id)
const newGroups = [...userToSave.auto_groups, ...resGroups]
let effect = yield call(service.editUser, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {
id: userToSave.id,
name: userToSave.name,
email: userToSave.email,
role: userToSave.role,
auto_groups: newGroups,
} as UserToSave
});
const response = effect as ApiResponse<User>;
yield put(actions.saveUser.success({
loading: false,
success: true,
failure: false,
error: null,
data: response.body
} as CreateResponse<User | null>));
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.getUsers.request({getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: null}));
} catch (err) {
yield put(actions.saveUser.failure({
loading: false,
success: false,
failure: false,
error: err as ApiError,
data: null
} as CreateResponse<User | null>));
}
}
export default function* sagas(): Generator {
yield all([
takeLatest(actions.getUsers.request, getUsers),
takeLatest(actions.saveUser.request, saveUser)
]);
yield all([
takeLatest(actions.getUsers.request, getUsers),
takeLatest(actions.saveUser.request, saveUser)
]);
}

View File

@@ -18,4 +18,11 @@ export default {
payload
);
},
async createUser(payload:RequestPayload<UserToSave>): Promise<ApiResponse<User>> {
// @ts-ignore
return apiClient.post<User>(
`/api/users`,
payload
);
},
};

View File

@@ -3,6 +3,7 @@ export interface User {
email?: string;
name: string;
role: string;
status: string
auto_groups: string[]
}

View File

@@ -1,4 +1,4 @@
import React, {useEffect, useState} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {actions as userActions} from '../store/user';
@@ -7,10 +7,11 @@ import {
Alert,
Button,
Card,
Col, Divider,
Col,
Dropdown,
Input,
Menu, message,
Menu,
message,
Popover,
Row,
Select,
@@ -28,6 +29,9 @@ import {actions as groupActions} from "../store/group";
import {Group} from "../store/group/types";
import {TooltipPlacement} from "antd/es/tooltip";
import {useOidcUser} from "@axa-fr/react-oidc";
import {Link} from "react-router-dom";
import {actions as setupKeyActions} from "../store/setup-key";
import {SetupKey} from "../store/setup-key/types";
const {Title, Paragraph, Text} = Typography;
const {Column} = Table;
@@ -134,6 +138,18 @@ export const Users = () => {
} as User));
}
const onClickInviteUser = () => {
const autoGroups : string[] = []
dispatch(userActions.setUpdateUserDrawerVisible(true));
dispatch(userActions.setUser({
id: "",
email: "",
auto_groups: autoGroups,
name: "",
role: "user",
} as User));
}
const renderPopoverGroups = (label: string, rowGroups: string[] | string[] | null, userToAction: UserDataTable) => {
let groupsMap = new Map<string, Group>();
@@ -205,10 +221,19 @@ export const Users = () => {
dispatch(userActions.setSavedUser({...savedUser, success: false}));
dispatch(userActions.resetSavedUser(null))
} else if (savedUser.error) {
let errorMsg = "Failed to update user"
switch (savedUser.error.statusCode) {
case 412:
errorMsg = savedUser.error.data
break
case 403:
errorMsg = "Failed to update user. You might not have enough permissions."
break
}
message.error({
content: 'Failed to update user. You might not have enough permissions.',
content: errorMsg,
key: createKey,
duration: 2,
duration: 5,
style: styleNotification
});
dispatch(userActions.setSavedUser({...savedUser, error: null}));
@@ -252,6 +277,18 @@ export const Users = () => {
onChange={onChangePageSize} className="select-rows-per-page-en"/>
</Space>
</Col>
<Col xs={24}
sm={24}
md={5}
lg={5}
xl={5}
xxl={5} span={5}>
<Row justify="end">
<Col>
<Button type="primary" onClick={onClickInviteUser}>Invite User</Button>
</Col>
</Row>
</Col>
</Row>
{failed &&
<Alert message={failed.code} description={failed.message} type="error" showIcon
@@ -291,6 +328,19 @@ export const Users = () => {
<Column title="Name" dataIndex="name"
onFilter={(value: string | number | boolean, record) => (record as any).name.includes(value)}
sorter={(a, b) => ((a as any).name.localeCompare((b as any).name))}/>
<Column title="Status" dataIndex="status"
align="center"
onFilter={(value: string | number | boolean, record) => (record as any).status.includes(value)}
sorter={(a, b) => ((a as any).status.localeCompare((b as any).status))}
render={(text, record, index) => {
if (text == "active") {
return <Tag color="green">{text}</Tag>
} else if (text === "invited"){
return <Tag color="gold">{text}</Tag>
}
return <Tag color="red">{text}</Tag>
}}
/>
<Column title="Groups" dataIndex="groupsCount" align="center"
render={(text, record: UserDataTable, index) => {
return renderPopoverGroups(text, record.auto_groups, record)