diff --git a/frontend/src/@types/index.ts b/frontend/src/@types/index.ts index 58c4e6530..5f52a099c 100644 --- a/frontend/src/@types/index.ts +++ b/frontend/src/@types/index.ts @@ -1,6 +1,6 @@ -export const enum ROLE { +export enum ROLE { ADMIN = 1, - MANAGER = 10, + DATA_MANAGER = 10, OPERATOR = 20, GUEST_OPERATOR = 30 } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6258a33cc..7e13ce301 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import Experiments from 'pages/Database/Experiments' import PublicExperiments from 'pages/PublicDatabase/PublicExperiments' import PublicCells from 'pages/PublicDatabase/PublicCells' import Cells from 'pages/Database/Cells' +import AccountManager from "./pages/AccountManager"; const App: React.FC = () => { return ( @@ -45,6 +46,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/users/UsersAdmin.ts b/frontend/src/api/users/UsersAdmin.ts index d30391da2..7cbf60cd3 100644 --- a/frontend/src/api/users/UsersAdmin.ts +++ b/frontend/src/api/users/UsersAdmin.ts @@ -3,9 +3,9 @@ import { AddUserDTO, UserDTO, ListUsersQueryDTO, - UserListDTO, - UpdateUserDTO, + UpdateUserDTO, UserListDTO, } from './UsersApiDTO' +import qs from "qs"; export const createUserApi = async (data: AddUserDTO): Promise => { const response = await axios.post('/admin/users', data) @@ -20,15 +20,16 @@ export const getUserApi = async (uid: string): Promise => { export const listUsersApi = async ( data: ListUsersQueryDTO, ): Promise => { - const response = await axios.get('/admin/users', { params: data }) + const paramsNew = qs.stringify(data, { indices: false }) + const response = await axios.get(`/admin/users?${paramsNew}`) return response.data } export const updateUserApi = async ( - uid: string, + id: number, data: UpdateUserDTO, ): Promise => { - const response = await axios.put(`/admin/users/${uid}`, data) + const response = await axios.put(`/admin/users/${id}`, data) return response.data } diff --git a/frontend/src/api/users/UsersApiDTO.ts b/frontend/src/api/users/UsersApiDTO.ts index 13c0d24cd..8b307c6e1 100644 --- a/frontend/src/api/users/UsersApiDTO.ts +++ b/frontend/src/api/users/UsersApiDTO.ts @@ -5,26 +5,35 @@ export type UserDTO = { name?: string organization_id?: number role_id?: number - create_at?: string - update_at?: string + created_at?: string + updated_at?: string } export type AddUserDTO = { email: string password: string + name: string + role_id: number } export type ListUsersQueryDTO = { + name?: string + email?: string + sort?: string[] offset?: number limit?: number } export type UserListDTO = { - data: UserDTO[] - total_page: number + items: UserDTO[] + total: number + limit: number + offset: number } export type UpdateUserDTO = { + role_id?: number, + name: string, email: string } diff --git a/frontend/src/components/Database/DatabaseCells.tsx b/frontend/src/components/Database/DatabaseCells.tsx index 31a4a623d..6c39448a1 100644 --- a/frontend/src/components/Database/DatabaseCells.tsx +++ b/frontend/src/components/Database/DatabaseCells.tsx @@ -118,7 +118,7 @@ const columns = (handleOpenDialog: (value: ImageUrls[], expId?: string) => void) const DatabaseCells = ({ user }: CellProps) => { const type: keyof TypeData = user ? 'private' : 'public' - const { data: dataExperiments, loading } = useSelector( + const { data: dataCells, loading } = useSelector( (state: RootState) => ({ data: state[DATABASE_SLICE_NAME].data[type], loading: state[DATABASE_SLICE_NAME].loading, @@ -140,11 +140,11 @@ const DatabaseCells = ({ user }: CellProps) => { const pagiFilter = useCallback( (page?: number) => { - return `limit=${dataExperiments.limit}&offset=${ - page ? page - 1 : dataExperiments.offset + return `limit=${dataCells.limit}&offset=${ + page ? page - 1 : dataCells.offset }` }, - [dataExperiments.limit, dataExperiments.offset], + [dataCells.limit, dataCells.offset], ) const id = searchParams.get('id') @@ -242,7 +242,7 @@ const DatabaseCells = ({ user }: CellProps) => { } const getColumns = useMemo(() => { - return (dataExperiments.header?.graph_titles || []).map( + return (dataCells.header?.graph_titles || []).map( (graphTitle, index) => ({ field: `graph_urls.${index}`, headerName: graphTitle, @@ -270,7 +270,7 @@ const DatabaseCells = ({ user }: CellProps) => { width: 160, }), ) - }, [dataExperiments.header?.graph_titles]) + }, [dataCells.header?.graph_titles]) const columnsTable = [...columns(handleOpenDialog), ...getColumns].filter( Boolean, @@ -280,7 +280,7 @@ const DatabaseCells = ({ user }: CellProps) => { { onFilterModelChange={handleFilter as any} /> { sx={{ cursor: 'pointer' }} onClick={() => handleOpenShare(row.experiment_id, value, row.id)} > - + ) } @@ -471,7 +471,7 @@ const DatabaseExperiments = ({ user, cellPath }: DatabaseProps) => { { /> void }> = ({ open, handleDrawerClose, }) => { const navigate = useNavigate() + const admin = useSelector(isAdmin) const onClickDashboard = () => { handleDrawerClose() @@ -31,7 +35,12 @@ const LeftMenu: FC<{ open: boolean; handleDrawerClose: () => void }> = ({ const onClickWorkspaces = () => { handleDrawerClose() - navigate('/console/workspaces') + navigate('/console/workspaces?limit=50&offset=0') + } + + const onClickAccountManager = () => { + handleDrawerClose() + navigate('/console/account-manager?sort=&sort=&limit=50&offset=0') } const onClickOpenSite = () => { @@ -68,6 +77,17 @@ const LeftMenu: FC<{ open: boolean; handleDrawerClose: () => void }> = ({ + { + admin ? + + + + + + + + : null + } diff --git a/frontend/src/components/PopupShare.tsx b/frontend/src/components/PopupShare.tsx index 8d5bff112..f86003c82 100644 --- a/frontend/src/components/PopupShare.tsx +++ b/frontend/src/components/PopupShare.tsx @@ -9,7 +9,7 @@ import { useDispatch, useSelector } from "react-redux"; import { postListUserShare } from "../store/slice/Database/DatabaseActions"; import CancelIcon from '@mui/icons-material/Cancel' import { postListUserShareWorkspaces } from "store/slice/Workspace/WorkspacesActions"; -import { selectListSearch, selectListSearchLoading } from "../store/slice/User/UserSelector"; +import { selectListSearch, selectLoading } from "../store/slice/User/UserSelector"; import { getListSearch } from "../store/slice/User/UserActions"; import Loading from "./common/Loading"; import { UserDTO } from "../api/users/UsersApiDTO"; @@ -78,7 +78,7 @@ const TableListSearch = ({usersSuggest, onClose, handleAddListUser, stateUserSha const PopupShare = ({open, handleClose, data, usersShare, id, isWorkspace, title}: PopupType) => { const [shareType, setShareType] = useState(data?.shareType || 0) const usersSuggest = useSelector(selectListSearch) - const loading = useSelector(selectListSearchLoading) + const loading = useSelector(selectLoading) const [textSearch, setTextSearch] = useState('') const [stateUserShare, setStateUserShare] = useState(usersShare || undefined) const dispatch = useDispatch() diff --git a/frontend/src/components/common/InputError.tsx b/frontend/src/components/common/InputError.tsx new file mode 100644 index 000000000..48fce526d --- /dev/null +++ b/frontend/src/components/common/InputError.tsx @@ -0,0 +1,61 @@ +import { InputProps, styled, Typography } from '@mui/material' + +interface InputErrorProps extends InputProps { + errorMessage: string + value?: string +} + +const InputError = + ({ + errorMessage, + onChange, + value, + type, + onBlur, + name, + }: InputErrorProps) => { + return ( + <> + + {errorMessage} + + ) +} + +const Input = styled('input', { + shouldForwardProp: (props) => props !== 'error', +})<{ error: boolean }>(({ error }) => { + return { + width: 250, + height: 24, + borderRadius: 4, + border: '1px solid', + borderColor: error ? 'red' : '#d9d9d9', + padding: '5px 10px', + marginBottom: 15, + transition: 'all 0.3s', + outline: 'none', + ':focus, :hover': { + borderColor: '#1677ff', + }, + } +}) + +const TextError = styled(Typography)({ + fontSize: 12, + minHeight: 18, + color: 'red', + lineHeight: '14px', + margin: '-14px 0px 0px 305px', + wordBreak: 'break-word', +}) + +export default InputError diff --git a/frontend/src/components/common/SelectError.tsx b/frontend/src/components/common/SelectError.tsx new file mode 100644 index 000000000..25a08ad02 --- /dev/null +++ b/frontend/src/components/common/SelectError.tsx @@ -0,0 +1,73 @@ +import { + MenuItem, + Select, + SelectChangeEvent, + styled, + Typography, +} from '@mui/material' +import { FC, FocusEvent } from 'react' + +type SelectErrorProps = { + value?: string + onChange?: (value: SelectChangeEvent, child: React.ReactNode) => void + onBlur?: (event: FocusEvent) => void + errorMessage: string + name?: string + options: string[] +} + +const SelectError: FC = + ({ + value, + onChange, + onBlur, + errorMessage, + options, + name, + }) => { + return ( + <> + , + child: React.ReactNode, + ) => void + } + onBlur={onBlur} + error={!!errorMessage} + > + {options.map((item: string) => { + return ( + + {item} + + ) + })} + + {errorMessage} + + ) +} + +const SelectModal = styled(Select, { + shouldForwardProp: (props) => props !== 'error', +})<{ error: boolean }>(({ theme, error }) => ({ + width: 272, + marginBottom: '15px', + border: '1px solid #d9d9d9', + borderColor: error ? 'red' : '#d9d9d9', + borderRadius: 4, +})) + +const TextError = styled(Typography)({ + fontSize: 12, + minHeight: 18, + color: 'red', + lineHeight: '14px', + margin: '-14px 0px 0px 305px', + wordBreak: 'break-word', +}) +export default SelectError diff --git a/frontend/src/pages/AccountManager/index.tsx b/frontend/src/pages/AccountManager/index.tsx new file mode 100644 index 000000000..7e8fe8eb0 --- /dev/null +++ b/frontend/src/pages/AccountManager/index.tsx @@ -0,0 +1,641 @@ +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import {ChangeEvent, useCallback, useEffect, useMemo, useState, MouseEvent} from "react"; +import {Box, Button, Input, Pagination, styled} from "@mui/material"; +import {useDispatch, useSelector} from "react-redux"; +import {isAdmin, selectCurrentUser, selectListUser, selectLoading} from "../../store/slice/User/UserSelector"; +import {useNavigate, useSearchParams} from "react-router-dom"; +import {createUser, getListUser, updateUser} from "../../store/slice/User/UserActions"; +import {DataGridPro} from "@mui/x-data-grid-pro"; +import Loading from "../../components/common/Loading"; +import {AddUserDTO, UserDTO} from "../../api/users/UsersApiDTO"; +import {ROLE} from "../../@types"; +import {GridFilterModel, GridSortDirection, GridSortModel} from "@mui/x-data-grid"; +import {regexEmail, regexIgnoreS, regexPassword} from "../../const/Auth"; +import InputError from "../../components/common/InputError"; +import {SelectChangeEvent} from "@mui/material/Select"; +import SelectError from "../../components/common/SelectError"; + +let timeout: NodeJS.Timeout | undefined = undefined + +type ModalComponentProps = { + onSubmitEdit: ( + id: number | string | undefined, + data: { [key: string]: string }, + ) => void + setOpenModal: (v: boolean) => void + dataEdit?: { + [key: string]: string + } +} + +const initState = { + email: '', + password: '', + role_id: '', + name: '', + confirmPassword: '', +} + +const ModalComponent = + ({ + onSubmitEdit, + setOpenModal, + dataEdit, + }: ModalComponentProps) => { + const [formData, setFormData] = useState<{ [key: string]: string }>( + dataEdit || initState, + ) + const [isDisabled, setIsDisabled] = useState(false) + const [errors, setErrors] = useState<{ [key: string]: string }>(initState) + + const validateEmail = (value: string): string => { + const error = validateField('email', 255, value) + if (error) return error + if (!regexEmail.test(value)) { + return 'Invalid email format' + } + return '' + } + + const validatePassword = ( + value: string, + isConfirm: boolean = false, + values?: { [key: string]: string }, + ): string => { + if (!value && !dataEdit?.uid) return 'This field is required' + const errorLength = validateLength('password', 255, value) + if (errorLength) { + return errorLength + } + let datas = values || formData + if (!regexPassword.test(value) && value) { + return 'Your password must be at least 6 characters long and must contain at least one letter, number, and special character' + } + if(regexIgnoreS.test(value)){ + return 'Allowed special characters (!#$%&()*+,-./@_|)' + } + if (isConfirm && datas.password !== value && value) { + return 'password is not match' + } + return '' + } + + const validateField = (name: string, length: number, value?: string) => { + if (!value) return 'This field is required' + return validateLength(name, length, value) + } + + const validateLength = (name: string, length: number, value?: string) => { + if (value && value.length > length) + return `The text may not be longer than ${length} characters` + if (formData[name]?.length && value && value.length > length) { + return `The text may not be longer than ${length} characters` + } + return '' + } + + const validateForm = (): { [key: string]: string } => { + const errorName = validateField('name', 100, formData.name) + const errorEmail = validateEmail(formData.email) + const errorRole = validateField('role_id', 50, formData.role_id) + const errorPassword = dataEdit?.id ? '' : validatePassword(formData.password) + const errorConfirmPassword = dataEdit?.id ? '' : validatePassword( + formData.confirmPassword, + true, + ) + return { + email: errorEmail, + password: errorPassword, + confirmPassword: errorConfirmPassword, + name: errorName, + role_id: errorRole, + } + } + + const onChangeData = ( + e: ChangeEvent | SelectChangeEvent, + length: number, + ) => { + const { value, name } = e.target + const newDatas = { ...formData, [name]: value } + setFormData(newDatas) + let error: string = + name === 'email' + ? validateEmail(value) + : validateField(name, length, value) + let errorConfirm = errors.confirmPassword + if (name.toLowerCase().includes('password')) { + error = validatePassword(value, name === 'confirmPassword', newDatas) + if (name !== 'confirmPassword' && formData.confirmPassword) { + errorConfirm = validatePassword( + newDatas.confirmPassword, + true, + newDatas, + ) + } + } + setErrors({ ...errors, confirmPassword: errorConfirm, [name]: error }) + } + + const onSubmit = async (e: MouseEvent) => { + e.preventDefault() + setIsDisabled(true) + const newErrors = validateForm() + if (Object.keys(newErrors).some((key) => !!newErrors[key])) { + setErrors(newErrors) + setIsDisabled(false) + return + } + try { + await onSubmitEdit(dataEdit?.id, formData) + setOpenModal(false) + } finally { + setIsDisabled(false) + } + } + const onCancel = () => { + setOpenModal(false) + } + + return ( + + + {dataEdit?.id ? 'Edit' : 'Add'} Account + + Name: + onChangeData(e, 100)} + onBlur={(e) => onChangeData(e, 100)} + errorMessage={errors.name} + /> + Role: + !Number(key))} + name="role_id" + onChange={(e) => onChangeData(e, 50)} + onBlur={(e) => onChangeData(e, 50)} + errorMessage={errors.role_id} + /> + e-mail: + onChangeData(e, 255)} + onBlur={(e) => onChangeData(e, 255)} + errorMessage={errors.email} + /> + {!dataEdit?.id ? ( + <> + Password: + onChangeData(e, 255)} + onBlur={(e) => onChangeData(e, 255)} + type={'password'} + errorMessage={errors.password} + /> + Confirm Password: + onChangeData(e, 255)} + onBlur={(e) => onChangeData(e, 255)} + type={'password'} + errorMessage={errors.confirmPassword} + /> + + ) : null} + + + + + + + {isDisabled ? : null} + + ) +} +const AccountManager = () => { + + const dispatch = useDispatch() + + const navigate = useNavigate() + + const listUser = useSelector(selectListUser) + const loading = useSelector(selectLoading) + const user = useSelector(selectCurrentUser) + const admin = useSelector(isAdmin) + + const [searchParams, setParams] = useSearchParams() + + const [openModal, setOpenModal] = useState(false) + const [dataEdit, setDataEdit] = useState({}) + + const limit = searchParams.get('limit') || 50 + const offset = searchParams.get('offset') || 0 + const name = searchParams.get('name') || undefined + const email = searchParams.get('email') || undefined + const sort = searchParams.getAll('sort') || [] + + useEffect(() => { + if(!admin) navigate('/console') + //eslint-disable-next-line + }, [JSON.stringify(admin)]) + + const sortParams = useMemo(() => { + return { + sort: sort + } + //eslint-disable-next-line + }, [JSON.stringify(sort)]) + + const filterParams = useMemo(() => { + return { + name: name, + email: email + } + }, [name, email]) + + const params = useMemo(() => { + return { + limit: Number(limit), + offset: Number(offset) + } + }, [limit, offset]) + + useEffect(() => { + dispatch(getListUser({...filterParams, ...sortParams, ...params})) + //eslint-disable-next-line + }, [searchParams]) + + const handlePage = (event: ChangeEvent, page: number) => { + if(!listUser) return + setParams(`limit=${listUser.limit}&offset=${page - 1}`) + } + + const getParamsData = () => { + const dataFilter = Object.keys(filterParams) + .filter((key) => (filterParams as any)[key]) + .map((key) => `${key}=${(filterParams as any)[key]}`) + .join('&') + return dataFilter + } + + const paramsManager = useCallback( + (page?: number) => { + return `limit=${limit}&offset=${ + page ? page - 1 : offset + }` + }, + [limit, offset], + ) + + const handleSort = useCallback( + (rowSelectionModel: GridSortModel) => { + const filter = getParamsData() + if (!rowSelectionModel[0]) { + setParams(`${filter}&sort=&sort=&${paramsManager()}`) + return + } + setParams( + `${filter}&sort=${rowSelectionModel[0].field.replace('_id', '')}&sort=${rowSelectionModel[0].sort}&${paramsManager()}`, + ) + }, + //eslint-disable-next-line + [paramsManager, getParamsData], + ) + + const handleFilter = (model: GridFilterModel) => { + let filter = '' + if (!!model.items[0]?.value) { + filter = model.items + .filter((item) => item.value) + .map((item: any) => { + return `${item.field}=${item?.value}` + }) + .join('&') + } + const { sort } = sortParams + setParams( + `${filter}&sort=${sort[0] || ''}&sort=${sort[1] || ''}&${paramsManager()}`, + ) + } + + const handleOpenModal = () => { + setOpenModal(true) + } + + const handleEdit = (dataEdit: UserDTO) => { + setOpenModal(true) + setDataEdit(dataEdit) + } + + const onSubmitEdit = async ( + id: number | string | undefined, + data: { [key: string]: string }, + ) => { + const {confirmPassword, role_id, ...newData} = data + let newRole + switch (role_id) { + case "ADMIN": + newRole = ROLE.ADMIN; + break; + case "DATA_MANAGER": + newRole = ROLE.DATA_MANAGER; + break; + case "OPERATOR": + newRole = ROLE.OPERATOR; + break; + case "GUEST_OPERATOR": + newRole = ROLE.GUEST_OPERATOR; + break; + } + if (id !== undefined) { + const data = await dispatch(updateUser( + { + id: id as number, + data: {name: newData.name, email: newData.email, role_id: newRole}, + params: {...filterParams, ...sortParams, ...params} + })) + if((data as any).error) { + setTimeout(() => { + alert('This email already exists!') + }, 300) + } + else { + setTimeout(() => { + alert('Your account has been edited successfully!') + }, 1) + } + } else { + const data = await dispatch(createUser({...newData, role_id: newRole} as AddUserDTO)) + if(!(data as any).error) { + setTimeout(() => { + alert('Your account has been created successfully!') + }, 1) + } + else { + setTimeout(() => { + alert('This email already exists!') + }, 300) + } + } + return undefined + } + + const columns = useMemo(() => + [ + { + headerName: 'UID', + field: 'uid', + filterable: false, + minWidth: 350 + }, + { + headerName: 'Name', + field: 'name', + minWidth: 200, + filterOperators: [ + { + label: 'Contains', value: 'contains', + InputComponent: ({applyValue, item}: any) => { + return { + if(timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + applyValue({...item, value: e.target.value}) + }, 300) + } + } /> + } + }, + ], + type: "string", + }, + { + headerName: 'Role', + field: 'role_id', + filterable: false, + minWidth: 200, + renderCell: (params: {value: number}) => { + let role + switch (params.value) { + case ROLE.ADMIN: + role = "Admin"; + break; + case ROLE.DATA_MANAGER: + role = "Data Manager"; + break; + case ROLE.OPERATOR: + role = "Operator"; + break; + case ROLE.GUEST_OPERATOR: + role = "Guest Operator"; + break; + } + return ( + {role} + ) + } + }, + { + headerName: 'Mail', + field: 'email', + minWidth: 350, + filterOperators: [ + { + label: 'Contains', value: 'contains', + InputComponent: ({applyValue, item}: any) => { + return { + if(timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + applyValue({...item, value: e.target.value}) + }, 300) + } + } /> + } + }, + ], + type: "string", + }, + { + headerName: '', + field: 'action', + sortable: false, + filterable: false, + width: 200, + renderCell: (params: {row: UserDTO}) => { + const { id, role_id, name, email} = params.row + if(!id || !role_id || !name || !email) return null + let role: any + switch (role_id) { + case ROLE.ADMIN: + role = "ADMIN"; + break; + case ROLE.DATA_MANAGER: + role = "DATA_MANAGER"; + break; + case ROLE.OPERATOR: + role = "OPERATOR"; + break; + case ROLE.GUEST_OPERATOR: + role = "GUEST_OPERATOR"; + break; + } + + return ( + <> + handleEdit({id, role_id: role, name, email} as UserDTO)} + > + + + { + !(params.row?.id === user?.id) ? + + + : null + } + + ) + }, + }, + ], + [user?.id], + ) + return ( + + + + + + { + listUser ? + : null + } + { + openModal ? + { + setOpenModal(flag) + if (!flag) { + setDataEdit({}) + } + }} + dataEdit={dataEdit} + /> : null + } + { + loading ? : null + } + + ) +} + +const AccountManagerWrapper = styled(Box)(({ theme }) => ({ + width: '80%', + margin: theme.spacing(6.125, 'auto') +})) + +const ALink = styled('a')({ + color: '#1677ff', + textDecoration: 'none', + cursor: 'pointer', + userSelect: 'none', +}) + +const Modal = styled(Box)(({ theme }) => ({ + position: 'fixed', + top: 0, + left: 0, + width: '100%', + height: '100vh', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#cccccc80', +})) + +const ModalBox = styled(Box)(({ theme }) => ({ + width: 800, + height: 550, + backgroundColor: 'white', + border: '1px solid black', +})) + +const TitleModal = styled(Box)(({ theme }) => ({ + fontSize: 25, + margin: theme.spacing(5), +})) + +const BoxData = styled(Box)(({ theme }) => ({ + marginTop: 35, +})) + +const LabelModal = styled(Box)(({ theme }) => ({ + width: 300, + display: 'inline-block', + textAlign: 'end', + marginRight: theme.spacing(0.5), +})) + +const ButtonModal = styled(Box)(({ theme }) => ({ + button: { + fontSize: 20, + }, + display: 'flex', + justifyContent: 'end', + margin: theme.spacing(5), +})) + +export default AccountManager diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index 64f493172..bacb1caaf 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -1,10 +1,12 @@ import { Link } from 'react-router-dom' import { Box, styled, Typography } from '@mui/material' import StorageIcon from '@mui/icons-material/Storage' +import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; import AccountCircleIcon from '@mui/icons-material/AccountCircle' import AnalyticsIcon from '@mui/icons-material/Analytics' import { useSelector } from 'react-redux' import { isAdmin } from 'store/slice/User/UserSelector' + const Dashboard = () => { const admin = useSelector(isAdmin) return ( @@ -28,13 +30,21 @@ const Dashboard = () => { + + + + + Account + + + { admin ? - + - - Account + + Account Manager : null diff --git a/frontend/src/pages/Workspace/index.tsx b/frontend/src/pages/Workspace/index.tsx index d63681eab..a3763b208 100644 --- a/frontend/src/pages/Workspace/index.tsx +++ b/frontend/src/pages/Workspace/index.tsx @@ -22,8 +22,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom' import Loading from '../../components/common/Loading' import { selectIsLoadingWorkspaceList, - selectWorkspaceData, - selectWorkspaceListUserShare, + selectWorkspaceData, selectWorkspaceListUserShare, } from 'store/slice/Workspace/WorkspaceSelector' import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react' import { @@ -42,7 +41,7 @@ import GroupsIcon from '@mui/icons-material/Groups'; import EditIcon from '@mui/icons-material/Edit'; import { selectCurrentUser } from 'store/slice/User/UserSelector' import { UserDTO } from 'api/users/UsersApiDTO' -import { isMe } from 'utils/checkRole' +import { isMine } from 'utils/checkRole' type PopupType = { open: boolean @@ -104,7 +103,7 @@ const columns = ( > {value} - {isMe(user, row?.user?.id) ? ( + {isMine(user, row?.user?.id) ? ( onEdit?.(row.id)}> @@ -124,7 +123,7 @@ const columns = ( ) => ( {params.value?.name} - {!isMe(user, params.value.id) ? : ''} + {!isMine(user, params.value.id) ? : ''} ), }, @@ -179,7 +178,7 @@ const columns = ( filterable: false, // todo enable when api complete sortable: false, // todo enable when api complete renderCell: (params: GridRenderCellParams) => - isMe(user, params.row?.user?.id) ? ( + isMine(user, params.row?.user?.id) ? ( handleOpenPopupShare(params.row.id)}> @@ -192,7 +191,7 @@ const columns = ( filterable: false, // todo enable when api complete sortable: false, // todo enable when api complete renderCell: (params: GridRenderCellParams) => - isMe(user, params.row?.user?.id) ? ( + isMine(user, params.row?.user?.id) ? ( handleOpenPopupDel(params.row.id, params.row.name)}> Del @@ -436,7 +435,15 @@ const Workspaces = () => { onChange={handleFileUpload} /> - New + { user ? @@ -468,7 +475,7 @@ const Workspaces = () => { ).filter(Boolean) as any } onRowModesModelChange={handleRowModesModelChange} - isCellEditable={(params) => isMe(user, params.row.user?.id)} + isCellEditable={(params) => isMine(user, params.row.user?.id)} onProcessRowUpdateError={onProcessRowUpdateError} onRowEditStop={onRowEditStop} processRowUpdate={processRowUpdate as any} @@ -478,8 +485,8 @@ const Workspaces = () => { } {open.share ? ( @@ -526,12 +533,17 @@ const WorkspacesWrapper = styled(Box)(({ theme }) => ({ const WorkspacesTitle = styled('h1')(({ theme }) => ({})) -const ButtonCustom = styled(Button)(({ theme }) => ({ +const ButtonCustom = styled('button')(({ theme }) => ({ backgroundColor: '#000000c4', color: '#FFF', fontSize: 16, padding: theme.spacing(0.5, 1.25), textTransform: 'unset', + borderRadius: 4, + height: 30, + display: 'flex', + alignItems: 'center', + cursor: 'pointer', '&:hover': { backgroundColor: '#000000fc', }, diff --git a/frontend/src/store/slice/User/UserActions.ts b/frontend/src/store/slice/User/UserActions.ts index 787c5459c..9e14cbaa0 100644 --- a/frontend/src/store/slice/User/UserActions.ts +++ b/frontend/src/store/slice/User/UserActions.ts @@ -1,8 +1,9 @@ import { createAsyncThunk } from '@reduxjs/toolkit' import { USER_SLICE_NAME } from './UserType' import { deleteMeApi, getMeApi, updateMeApi } from 'api/users/UsersMe' -import {UpdateUserDTO, UserDTO} from 'api/users/UsersApiDTO' +import {AddUserDTO, ListUsersQueryDTO, UpdateUserDTO, UserDTO} from 'api/users/UsersApiDTO' import { LoginDTO, loginApi } from 'api/auth/Auth' +import {createUserApi, listUsersApi, updateUserApi} from "../../../api/users/UsersAdmin"; import {getListSearchApi} from "../../../api/users/UsersAdmin"; export const login = createAsyncThunk( @@ -53,6 +54,18 @@ export const deleteMe = createAsyncThunk( }, ) +export const getListUser = createAsyncThunk( + `${USER_SLICE_NAME}/getListUser`, + async (params: ListUsersQueryDTO, thunkAPI) => { + try { + const responseData = await listUsersApi(params) + return responseData + } catch (e) { + return thunkAPI.rejectWithValue(e) + } + }, +) + export const getListSearch = createAsyncThunk< UserDTO[], {keyword: string | null} @@ -67,3 +80,35 @@ export const getListSearch = createAsyncThunk< } }, ) + +export const createUser = createAsyncThunk< + UserDTO, + AddUserDTO +>( + `${USER_SLICE_NAME}/createUser`, + async (params, thunkAPI) => { + try { + const responseData = await createUserApi(params) + return responseData + } catch (e) { + return thunkAPI.rejectWithValue(e) + } + }, +) + +export const updateUser = createAsyncThunk< + UserDTO, + {id: number, data: UpdateUserDTO, params: ListUsersQueryDTO} +>( + `${USER_SLICE_NAME}/updateUser`, + async (props, thunkAPI) => { + const { dispatch } = thunkAPI + try { + const responseData = await updateUserApi(props.id, props.data) + await dispatch(getListUser(props.params)) + return responseData + } catch (e) { + return thunkAPI.rejectWithValue(e) + } + }, +) diff --git a/frontend/src/store/slice/User/UserSelector.ts b/frontend/src/store/slice/User/UserSelector.ts index 5854521af..4c8cfb947 100644 --- a/frontend/src/store/slice/User/UserSelector.ts +++ b/frontend/src/store/slice/User/UserSelector.ts @@ -4,17 +4,17 @@ import { RootState } from 'store/store' export const selectCurrentUser = (state: RootState) => state.user.currentUser export const selectCurrentUserId = (state: RootState) => selectCurrentUser(state)?.id +export const selectListUser = (state: RootState) => state.user.listUser +export const selectLoading = (state: RootState) => state.user.loading export const selectCurrentUserUid = (state: RootState) => selectCurrentUser(state)?.uid export const selectCurrentUserEmail = (state: RootState) => selectCurrentUser(state)?.email export const selectListSearch = (state: RootState) => state.user.listUserSearch -export const selectListSearchLoading = (state: RootState) => state.user.loading - export const isAdmin = (state: RootState) => { return state.user && ROLE.ADMIN === state.user.currentUser?.role_id } export const isAdminOrManager = (state: RootState) => { - return [ROLE.ADMIN, ROLE.MANAGER].includes(state.user.currentUser?.role_id as number) + return [ROLE.ADMIN, ROLE.DATA_MANAGER].includes(state.user.currentUser?.role_id as number) } diff --git a/frontend/src/store/slice/User/UserSlice.ts b/frontend/src/store/slice/User/UserSlice.ts index ff01f7a7c..e96b662ae 100644 --- a/frontend/src/store/slice/User/UserSlice.ts +++ b/frontend/src/store/slice/User/UserSlice.ts @@ -1,7 +1,7 @@ import { createSlice, isAnyOf } from '@reduxjs/toolkit' import { USER_SLICE_NAME } from './UserType' import { User } from './UserType' -import {deleteMe, getListSearch, getMe, login, updateMe} from './UserActions' +import {deleteMe, getListUser, getListSearch, getMe, login, updateMe, createUser, updateUser} from './UserActions' import { removeExToken, removeToken, @@ -13,6 +13,7 @@ import { const initialState: User = { currentUser: undefined, listUserSearch: undefined, + listUser: undefined, loading: false } @@ -39,19 +40,42 @@ export const userSlice = createSlice({ .addCase(getMe.fulfilled, (state, action) => { state.currentUser = action.payload }) + .addCase(getListUser.fulfilled, (state, action) => { + state.listUser = action.payload + state.loading = false + }) .addCase(updateMe.fulfilled, (state, action) => { state.currentUser = action.payload }) - .addCase(getListSearch.pending, (state, action) => { - state.loading = true - }) .addCase(getListSearch.fulfilled, (state, action) => { state.loading = false state.listUserSearch = action.payload }) - .addCase(getListSearch.rejected, (state) => { + .addCase(createUser.fulfilled, (state, action) => { + if(!state.listUser) return + state.listUser.items.push(action.payload) state.loading = false }) + .addMatcher( + isAnyOf( + getListSearch.rejected, + createUser.rejected, + getListUser.rejected, + updateUser.rejected), + (state) => { + state.loading = false + }, + ) + .addMatcher( + isAnyOf( + getListUser.pending, + getListSearch.pending, + createUser.pending, + updateUser.pending), + (state) => { + state.loading = true + }, + ) .addMatcher( isAnyOf(login.rejected, getMe.rejected, deleteMe.fulfilled), (state) => { diff --git a/frontend/src/store/slice/User/UserType.ts b/frontend/src/store/slice/User/UserType.ts index 9f3cc5d2f..cefc57e11 100644 --- a/frontend/src/store/slice/User/UserType.ts +++ b/frontend/src/store/slice/User/UserType.ts @@ -1,4 +1,4 @@ -import { UserDTO } from 'api/users/UsersApiDTO' +import {UserListDTO, UserDTO} from 'api/users/UsersApiDTO' export const USER_SLICE_NAME = 'user' @@ -6,4 +6,5 @@ export type User = { currentUser?: UserDTO listUserSearch?: UserDTO[] loading: boolean + listUser?: UserListDTO } diff --git a/frontend/src/utils/checkRole.ts b/frontend/src/utils/checkRole.ts index a98b4c85d..cdbe882db 100644 --- a/frontend/src/utils/checkRole.ts +++ b/frontend/src/utils/checkRole.ts @@ -1,5 +1,5 @@ import { UserDTO } from "api/users/UsersApiDTO" -export const isMe = (user?: UserDTO, idUserWorkSpace?: number) => { +export const isMine = (user?: UserDTO, idUserWorkSpace?: number) => { return !!(user && idUserWorkSpace && user.id === idUserWorkSpace) } diff --git a/studio/app/common/core/users/crud_users.py b/studio/app/common/core/users/crud_users.py index b207930c8..8028d6db9 100644 --- a/studio/app/common/core/users/crud_users.py +++ b/studio/app/common/core/users/crud_users.py @@ -5,6 +5,7 @@ from sqlmodel import Session, select from studio.app.common.core.auth.auth import authenticate_user +from studio.app.common.models import Role as RoleModel from studio.app.common.models import User as UserModel from studio.app.common.models import UserRole as UserRoleModel from studio.app.common.schemas.auth import UserAuth @@ -12,8 +13,10 @@ User, UserCreate, UserPasswordUpdate, + UserSearchOptions, UserUpdate, ) +from studio.app.optinist.schemas.base import SortOptions async def set_role(db: Session, user_id: int, role_id: int, auto_commit=True): @@ -52,20 +55,33 @@ async def get_user(db: Session, user_id: int, organization_id: int) -> User: raise HTTPException(status_code=400, detail=str(e)) -async def list_user(db: Session, organization_id: int): +async def list_user( + db: Session, + organization_id: int, + options: UserSearchOptions, + sortOptions: SortOptions, +): try: - query = ( - select( - UserRoleModel.role_id, - *UserModel.__table__.columns, - ) + sa_sort_list = sortOptions.get_sa_sort_list( + sa_table=UserModel, + mapping={"role_id": UserRoleModel.role_id, "role": RoleModel.role}, + ) + users = paginate( + db, + query=select(UserRoleModel.role_id, *UserModel.__table__.columns) .join(UserRoleModel, UserRoleModel.user_id == UserModel.id) + .join(RoleModel, RoleModel.id == UserRoleModel.role_id) + .filter( + UserModel.active.is_(True), + UserModel.organization_id == organization_id, + ) .filter( - UserModel.active.is_(True), UserModel.organization_id == organization_id + UserModel.name.like("%{0}%".format(options.name)), + UserModel.email.like("%{0}%".format(options.email)), ) - .order_by(UserModel.name) + .order_by(*sa_sort_list), + unique=False, ) - users = paginate(db, query=query, unique=False) return users except Exception as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/studio/app/common/routers/users_admin.py b/studio/app/common/routers/users_admin.py index fb708f3f3..e5a10152f 100644 --- a/studio/app/common/routers/users_admin.py +++ b/studio/app/common/routers/users_admin.py @@ -5,7 +5,13 @@ from studio.app.common.core.auth.auth_dependencies import get_admin_user from studio.app.common.core.users import crud_users from studio.app.common.db.database import get_db -from studio.app.common.schemas.users import User, UserCreate, UserUpdate +from studio.app.common.schemas.users import ( + User, + UserCreate, + UserSearchOptions, + UserUpdate, +) +from studio.app.optinist.schemas.base import SortOptions router = APIRouter( prefix="/admin/users", tags=["users/admin"], dependencies=[Depends(get_admin_user)] @@ -15,9 +21,16 @@ @router.get("", response_model=LimitOffsetPage[User]) async def list_user( db: Session = Depends(get_db), + options: UserSearchOptions = Depends(), + sortOptions: SortOptions = Depends(), current_admin: User = Depends(get_admin_user), ): - return await crud_users.list_user(db, organization_id=current_admin.organization_id) + return await crud_users.list_user( + db, + organization_id=current_admin.organization_id, + options=options, + sortOptions=sortOptions, + ) @router.post("", response_model=User) diff --git a/studio/app/common/schemas/users.py b/studio/app/common/schemas/users.py index 56fe6c20c..6f46fb0ae 100644 --- a/studio/app/common/schemas/users.py +++ b/studio/app/common/schemas/users.py @@ -2,11 +2,17 @@ from enum import Enum from typing import Optional +from fastapi import Query from pydantic import BaseModel, EmailStr, Field password_regex = r"^(?=.*\d)(?=.*[!#$%&()*+,-./@_|])(?=.*[a-zA-Z]).{6,255}$" +class UserSearchOptions(BaseModel): + email: Optional[str] = Field(Query(default="")) + name: Optional[str] = Field(Query(default="")) + + class UserRole(int, Enum): admin = 1 data_manager = 10