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 (
+
+ )
+ })}
+
+ {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