From 938d5313fbd137b33c67065f120793f2b407fa99 Mon Sep 17 00:00:00 2001 From: SungChul Hong Date: Thu, 17 Oct 2024 18:22:19 +0900 Subject: [PATCH] feat: migrate user list in user credential page to react component --- react/src/App.tsx | 4 + react/src/components/BAIPropertyFilter.tsx | 2 +- react/src/components/UserInfoModal.tsx | 41 +-- react/src/components/UserList.tsx | 385 +++++++++++++++++++++ react/src/components/UserSettingModal.tsx | 44 +-- react/src/index.tsx | 26 -- react/src/pages/UserCredentialsPage.tsx | 59 ++++ resources/i18n/de.json | 3 +- resources/i18n/el.json | 3 +- resources/i18n/en.json | 3 +- resources/i18n/es.json | 3 +- resources/i18n/fi.json | 3 +- resources/i18n/fr.json | 3 +- resources/i18n/id.json | 3 +- resources/i18n/it.json | 3 +- resources/i18n/ja.json | 3 +- resources/i18n/ko.json | 3 +- resources/i18n/mn.json | 3 +- resources/i18n/ms.json | 3 +- resources/i18n/pl.json | 3 +- resources/i18n/pt-BR.json | 3 +- resources/i18n/pt.json | 3 +- resources/i18n/ru.json | 3 +- resources/i18n/th.json | 3 +- resources/i18n/tr.json | 3 +- resources/i18n/vi.json | 3 +- resources/i18n/zh-CN.json | 3 +- resources/i18n/zh-TW.json | 3 +- src/backend-ai-app.ts | 4 - src/components/backend-ai-webui.ts | 8 - 30 files changed, 515 insertions(+), 121 deletions(-) create mode 100644 react/src/components/UserList.tsx create mode 100644 react/src/pages/UserCredentialsPage.tsx diff --git a/react/src/App.tsx b/react/src/App.tsx index 823d0b3775..98b76919d6 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -56,6 +56,9 @@ const InteractiveLoginPage = React.lazy( () => import('./pages/InteractiveLoginPage'), ); const ImportAndRunPage = React.lazy(() => import('./pages/ImportAndRunPage')); +const UserCredentialsPage = React.lazy( + () => import('./pages/UserCredentialsPage'), +); const ComputeSessionList = React.lazy( () => import('./components/ComputeSessionList'), @@ -336,6 +339,7 @@ const router = createBrowserRouter([ { path: '/credential', handle: { labelKey: 'webui.menu.UserCredentials&Policies' }, + Component: UserCredentialsPage, }, { path: '/logs', diff --git a/react/src/components/BAIPropertyFilter.tsx b/react/src/components/BAIPropertyFilter.tsx index b7e1009c8a..ef694bf907 100644 --- a/react/src/components/BAIPropertyFilter.tsx +++ b/react/src/components/BAIPropertyFilter.tsx @@ -26,7 +26,7 @@ import React, { import { useTranslation } from 'react-i18next'; //github.com/lablup/backend.ai/blob/main/src/ai/backend/manager/models/minilang/queryfilter.py -type FilterProperty = { +export type FilterProperty = { key: string; // operators: Array; defaultOperator?: string; diff --git a/react/src/components/UserInfoModal.tsx b/react/src/components/UserInfoModal.tsx index 87cd0ce81c..33dc721fc7 100644 --- a/react/src/components/UserInfoModal.tsx +++ b/react/src/components/UserInfoModal.tsx @@ -1,7 +1,6 @@ import { useSuspendedBackendaiClient } from '../hooks'; import { useTOTPSupported } from '../hooks/backendai'; import BAIModal, { BAIModalProps } from './BAIModal'; -import { useWebComponentInfo } from './DefaultProviders'; import { UserInfoModalQuery } from './__generated__/UserInfoModalQuery.graphql'; import { Descriptions, DescriptionsProps, Button, Tag, Spin } from 'antd'; import graphql from 'babel-plugin-relay/macro'; @@ -10,26 +9,17 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useLazyLoadQuery } from 'react-relay'; -interface Props extends BAIModalProps {} +interface Props extends BAIModalProps { + userEmail: string; + onRequestClose: () => void; +} -const UserInfoModal: React.FC = ({ ...baiModalProps }) => { +const UserInfoModal: React.FC = ({ + userEmail, + onRequestClose, + ...baiModalProps +}) => { const { t } = useTranslation(); - - const { value, dispatchEvent } = useWebComponentInfo(); - let parsedValue: { - open: boolean; - userEmail: string; - }; - try { - parsedValue = JSON.parse(value || ''); - } catch (error) { - parsedValue = { - open: false, - userEmail: '', - }; - } - const { open, userEmail } = parsedValue; - const baiClient = useSuspendedBackendaiClient(); const sudoSessionEnabledSupported = baiClient?.supports( 'sudo-session-enabled', @@ -87,23 +77,14 @@ const UserInfoModal: React.FC = ({ ...baiModalProps }) => { return ( { - dispatchEvent('cancel', null); - }} centered title={t('credential.UserDetail')} footer={[ - , ]} + onCancel={onRequestClose} {...baiModalProps} >
diff --git a/react/src/components/UserList.tsx b/react/src/components/UserList.tsx new file mode 100644 index 0000000000..8abf9af574 --- /dev/null +++ b/react/src/components/UserList.tsx @@ -0,0 +1,385 @@ +import { filterNonNullItems, localeCompare } from '../helper'; +import { useSuspendedBackendaiClient, useUpdatableState } from '../hooks'; +import BAIPropertyFilter, { FilterProperty } from './BAIPropertyFilter'; +import BAITable from './BAITable'; +import Flex from './Flex'; +import UserInfoModal from './UserInfoModal'; +import UserSettingModal from './UserSettingModal'; +import { UserListDeleteMutation } from './__generated__/UserListDeleteMutation.graphql'; +import { + UserListQuery, + UserListQuery$data, +} from './__generated__/UserListQuery.graphql'; +import { + DeleteOutlined, + InfoCircleOutlined, + LoadingOutlined, + ReloadOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { useToggle } from 'ahooks'; +import { App, Button, Popconfirm, Radio, theme } from 'antd'; +import graphql from 'babel-plugin-relay/macro'; +import dayjs from 'dayjs'; +import _ from 'lodash'; +import { Suspense, useDeferredValue, useState, useTransition } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLazyLoadQuery, useMutation } from 'react-relay'; + +type User = NonNullable< + NonNullable['user_list'] +>['items'][number]; + +const UserList: React.FC = () => { + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { message } = App.useApp(); + const [isOpenInfoModal, { toggle: toggleInfoModalOpen }] = useToggle(false); + const [isOpenSettingModal, { toggle: toggleSettingModal }] = useToggle(false); + const [selectedUserEmail, setSelectedUserEmail] = useState( + null, + ); + const [filterString, setFilterString] = useState(); + const [isPendingFilter, startFilterTransition] = useTransition(); + const [activeType, setActiveType] = useState<'active' | 'inactive'>('active'); + const [isActiveTypePending, startActiveTypeTransition] = useTransition(); + const [userListFetchKey, updateUserListFetchKey] = + useUpdatableState('initial-fetch'); + const [isPendingReload, startReloadTransition] = useTransition(); + const [paginationState, setPaginationState] = useState<{ + current: number; + pageSize: number; + }>({ + current: 1, + pageSize: 10, + }); + const deferredPaginationState = useDeferredValue(paginationState); + const baiClient = useSuspendedBackendaiClient(); + const filterProperties: FilterProperty[] = [ + { + key: 'email', + propertyLabel: t('credential.UserID'), + type: 'string', + }, + { + key: 'username', + propertyLabel: t('credential.UserName'), + type: 'string', + }, + { + key: 'role', + propertyLabel: t('credential.Role'), + type: 'string', + defaultOperator: '==', + options: [ + { + label: 'super admin', + value: 'SUPERADMIN', + }, + { + label: 'admin', + value: 'ADMIN', + }, + { + label: 'user', + value: 'user', + }, + { + label: 'monitor', + value: 'MONITOR', + }, + ], + }, + ]; + + /**FIXME: + * There is currently an issue with using @skipOnClient or @include within a Fragment. + * So it is currently implemented to use independent queries within `UserInfoModal.tsx` and `UserSettingModal.tsx`. + * + * Issue 1. @skipOnClient does not work inside a fragment. + * Issue 2. variables used in @include or other conditions can't be referenced in fragment with more than 2depth + * */ + const { user_list } = useLazyLoadQuery( + graphql` + query UserListQuery( + $limit: Int! + $offset: Int! + $is_active: Boolean! + $filter: String + ) { + user_list( + limit: $limit + offset: $offset + is_active: $is_active + filter: $filter + ) { + items { + username + email + created_at + role + main_access_key @since(version: "23.09.7") + } + total_count + } + } + `, + { + offset: + (deferredPaginationState.current - 1) * + deferredPaginationState.pageSize, + limit: deferredPaginationState.pageSize, + is_active: activeType === 'active', + filter: filterString, + }, + { + fetchPolicy: 'store-and-network', + fetchKey: userListFetchKey, + }, + ); + + const [commitDeleteUser, isInFlightCommitDeleteUser] = + useMutation(graphql` + mutation UserListDeleteMutation($email: String!) { + delete_user(email: $email) { + ok + msg + } + } + `); + + return ( + + + + { + startActiveTypeTransition(() => { + setActiveType(value.target.value); + }); + setPaginationState({ + current: 1, + pageSize: paginationState.pageSize, + }); + }} + optionType="button" + buttonStyle="solid" + options={[ + { + label: t('credential.Active'), + value: 'active', + }, + { + label: t('credential.Inactive'), + value: 'inactive', + }, + ]} + /> + { + startFilterTransition(() => { + setFilterString(value); + }); + }} + filterProperties={ + baiClient.isManagerVersionCompatibleWith('23.09.7') + ? _.concat(filterProperties, [ + { + key: 'main_access_key', + propertyLabel: t('credential.MainAccessKey'), + type: 'string', + }, + ]) + : filterProperties + } + /> + + + + + + , + }} + bordered={false} + dataSource={filterNonNullItems(user_list?.items)} + sortDirections={['descend', 'ascend', 'descend']} + showSorterTooltip={false} + columns={[ + { + title: '#', + fixed: 'left', + render: (text, record, index) => { + ++index; + return index; + }, + width: 50, + showSorterTooltip: false, + rowScope: 'row', + }, + { + title: t('credential.UserID'), + dataIndex: 'email', + sorter: (a, b) => localeCompare(a.email, b.email), + }, + { + title: t('credential.Name'), + dataIndex: 'username', + sorter: (a, b) => localeCompare(a.username, b.username), + }, + { + title: t('credential.Role'), + dataIndex: 'role', + sorter: (a, b) => localeCompare(a.role, b.role), + }, + { + title: t('credential.MainAccessKey'), + dataIndex: 'main_access_key', + sorter: (a, b) => + localeCompare(a.main_access_key, b.main_access_key), + }, + { + title: t('credential.CreatedAt'), + dataIndex: 'created_at', + render: (text) => dayjs(text).format('lll'), + sorter: (a, b) => localeCompare(a?.created_at, b?.created_at), + }, + { + title: t('general.Control'), + render: (record) => { + return ( + +