From 18cfddbbe75b10f63ec6be4a1c694cff8b6c24ac Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Thu, 13 Oct 2022 18:01:32 +0200 Subject: [PATCH] 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. --- src/App.tsx | 73 ++++--- src/components/Loading.tsx | 27 ++- src/components/LoginError.tsx | 14 +- src/components/UserInvite.tsx | 377 ++++++++++++++++++++++++++++++++++ src/components/UserUpdate.tsx | 58 ++++-- src/index.tsx | 2 +- src/store/user/sagas.ts | 148 +++++++------ src/store/user/service.ts | 7 + src/store/user/types.ts | 1 + src/views/Users.tsx | 60 +++++- 10 files changed, 649 insertions(+), 118 deletions(-) create mode 100644 src/components/UserInvite.tsx diff --git a/src/App.tsx b/src/App.tsx index 7aa5226..2aeb652 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('GET', `/api/users`, {getAccessTokenSilently: getAccessTokenSilently}) + .then(() => { + setShow(true) + }) + .catch(e => { + setShow(true) + console.log(e) + }) + } + + }, [getAccessTokenSilently]) return ( - + <> + + {!show && } + {show &&
- +
-
- ); + } +
+ + ) } export default App; \ No newline at end of file diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index d973655..44e0548 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -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 = ({padding, width, height}) => ( - - Loading +const Loading: React.FC = ({padding, width, height}) => ( + + Loading ); 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) => ( + + + +); diff --git a/src/components/LoginError.tsx b/src/components/LoginError.tsx index eaf68ae..6b5efff 100644 --- a/src/components/LoginError.tsx +++ b/src/components/LoginError.tsx @@ -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 + + + } + > +
+ + +
+ + {/*Close Icon*/} + + {user.id && + + } + + {/* Name Label*/} + +
+ {formUser.name}
+ +
+
+ + + + + + + + + + + + + + + + + + + {/**/} + + {currentUser && currentUser.role !== "admin" && ( +
+ + + You are not an administrator, therefore you can't update users.
} + showIcon={false} + type="warning"/> + +

+ + )} +
+
+ + + } + + ) +} + +export default UserInvite \ No newline at end of file diff --git a/src/components/UserUpdate.tsx b/src/components/UserUpdate.tsx index 4971ab5..ea6d6ed 100644 --- a/src/components/UserUpdate.tsx +++ b/src/components/UserUpdate.tsx @@ -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(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 = 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? + Your user will be limited to read-only operations in this account. Are you sure? let contentModule =
{content}
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 = () => { + onClick={handleFormSubmit}>{`${formUser.id ? 'Save' : 'Invite'}`} } > @@ -278,7 +295,7 @@ const UserUpdate = () => { {/*Close Icon*/} - {user.id && + {!editName && user.id && */} {currentUser && currentUser.role !== "admin" && (
diff --git a/src/index.tsx b/src/index.tsx index f19eafc..47e7feb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -41,7 +41,7 @@ const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); -const loadingComponent = () => +const loadingComponent = () => root.render( ): Generator { - try { - const effect = yield call(service.getUsers, action.payload); - const response = effect as ApiResponse; + try { + const effect = yield call(service.getUsers, action.payload); + const response = effect as ApiResponse; - 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): Generator { - try { - yield put(actions.setSavedUser({ - loading: true, - success: false, - failure: false, - error: null, - data: null - } as CreateResponse)) + try { + yield put(actions.setSavedUser({ + loading: true, + success: false, + failure: false, + error: null, + data: null + } as CreateResponse)) - 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[]).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; + + yield put(actions.saveUser.success({ + loading: false, + success: true, + failure: false, + error: null, + data: response.body + } as CreateResponse)); + + yield put(groupActions.getGroups.request({ + getAccessTokenSilently: action.payload.getAccessTokenSilently, + payload: null + })); + yield put(actions.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)); } - - // first, create groups that were newly added by user - const responsesGroup = yield all(groupsToCreate.map(g => call(serviceGroup.createGroup, { - getAccessTokenSilently: action.payload.getAccessTokenSilently, - payload: {name: g} - }) - )) - - const resGroups = (responsesGroup as ApiResponse[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id) - const newGroups = [...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; - - yield put(actions.saveUser.success({ - loading: false, - success: true, - failure: false, - error: null, - data: response.body - } as CreateResponse)); - - yield put(groupActions.getGroups.request({ - getAccessTokenSilently: action.payload.getAccessTokenSilently, - payload: null - })); - yield put(actions.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)); - } } 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) + ]); } diff --git a/src/store/user/service.ts b/src/store/user/service.ts index 861fb7d..34ba2f2 100644 --- a/src/store/user/service.ts +++ b/src/store/user/service.ts @@ -18,4 +18,11 @@ export default { payload ); }, + async createUser(payload:RequestPayload): Promise> { + // @ts-ignore + return apiClient.post( + `/api/users`, + payload + ); + }, }; diff --git a/src/store/user/types.ts b/src/store/user/types.ts index d4ce156..90731ad 100644 --- a/src/store/user/types.ts +++ b/src/store/user/types.ts @@ -3,6 +3,7 @@ export interface User { email?: string; name: string; role: string; + status: string auto_groups: string[] } diff --git a/src/views/Users.tsx b/src/views/Users.tsx index 3e817bd..6a99bf8 100644 --- a/src/views/Users.tsx +++ b/src/views/Users.tsx @@ -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(); @@ -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"/> + + + + + + + {failed && { (record as any).name.includes(value)} sorter={(a, b) => ((a as any).name.localeCompare((b as any).name))}/> + (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 {text} + } else if (text === "invited"){ + return {text} + } + return {text} + }} + /> { return renderPopoverGroups(text, record.auto_groups, record)