import { PageAction } from '@/config'; import { PaginationKey } from '@/config/settings'; import type { PageActionType } from '@/config/types'; import useTableFetch from '@/hooks/use-table-fetch'; import useQueryUserList from '@/pages/users/services/use-query-user-list'; import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui'; import { useAccess, useIntl } from '@umijs/max'; import useMemoizedFn from 'ahooks/lib/useMemoizedFn'; import { ConfigProvider, Table } from 'antd'; import _ from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import PageBox from '../_components/page-box'; import { deleteApisKey, queryApisKeysList } from './apis'; import AddAPIKeyModal from './components/add-apikey-modal'; import { ListItem } from './config/types'; import useKeysColumns from './hooks/use-keys-columns'; import { APIKeyConfigActionMount, getAPIKeyConfigActions, type APIKeyConfigActionController } from './plugin'; const APIKeys: React.FC = () => { const access = useAccess(); // `canSeeOrgAdmin` widens to Org owners in the enterprise build — // mirrors the BE's "platform admin OR current-Org owner" gate on // listing every key in scope. Personal/member users continue to see // only their own keys (`user_id: undefined`). const canSeeAllKeys = !!access.canSeeOrgAdmin; const { TABLE_SORT_DIRECTIONS, dataSource, rowSelection, queryParams, sortOrder, modalRef, handleDelete, handleDeleteBatch, fetchData, handlePageChange, handleTableChange, handleQueryChange, handleSearch, handleNameChange } = useTableFetch({ key: PaginationKey.APIKeys, fetchAPI: queryApisKeysList, deleteAPI: deleteApisKey, contentForDelete: 'apikeys.table.apikeys', defaultQueryParams: { user_id: canSeeAllKeys ? '*' : undefined } }); const { dataList: userList, fetchData: fetchUserData, cancelRequest: cancelUserRequest } = useQueryUserList({ getLabel: (item) => item.username, getValue: (item) => item.id }); const intl = useIntl(); // Generic per-row plugin slot. Each enterprise plugin contributes a // `{ key, labelId, icon, priority, form, useCreate }` entry; the // host renders a button per entry in the dropdown and renders one // `APIKeyConfigActionMount` per entry — those mounts own each // entry's controller and register it back into `controllersRef` so // dropdown clicks can route to the correct `openModal`. See // `./plugin.tsx`. // // The action list is read once. Plugins are registered at boot and // never recompute, so the reference is stable for the lifetime of // the page and `useMemo([])` is safe. const configActions = useMemo(() => getAPIKeyConfigActions(), []); const controllersRef = useRef>( {} ); const registerController = useCallback( (key: string, controller: APIKeyConfigActionController) => { controllersRef.current[key] = controller; }, [] ); const [openAddModal, setOpenAddModal] = useState<{ open: boolean; action: PageActionType; title: string; currentData?: Partial | null; }>({ open: false, action: PageAction.CREATE, title: '', currentData: null }); useEffect(() => { // `scope=current_org` limits the creator dropdown to members of the // active Org. Without it, an Org owner sees every user in the // system — most of whom can't own a key visible in this list, so // selecting them produces an empty result. The BE drops the // param silently when the request has no Org context, so callers // without an Org keep the full-directory behavior. fetchUserData({ page: -1, scope: 'current_org' }); return () => { cancelUserRequest(); }; }, []); const handleAddKey = () => { setOpenAddModal({ open: true, title: intl.formatMessage({ id: 'apikeys.button.create' }), action: PageAction.CREATE, currentData: null }); }; const handleEditKey = (record: ListItem) => { setOpenAddModal({ open: true, title: intl.formatMessage({ id: 'apikeys.button.edit' }), action: PageAction.EDIT, currentData: record }); }; const handleModalOk = async () => { try { await fetchData(); } catch (error) { // do nothing } }; const handleModalCancel = () => { setOpenAddModal({ open: false, title: '', action: PageAction.CREATE, currentData: null }); }; const onSelect = useMemoizedFn( async ( val: string, record: ListItem, item?: { onClick?: (r: ListItem) => void } ) => { if (item?.onClick) { item.onClick(record); return; } if (val === 'delete') { handleDelete(record); } else if (val === 'edit') { handleEditKey(record); } } ); // Each plugin entry's button onClick routes here. The controller // registry is populated by each `APIKeyConfigActionMount` on mount. const handleConfigAction = useMemoizedFn( (actionKey: string, record: ListItem) => { controllersRef.current[actionKey]?.openModal(record); } ); const handleUserChange = (val: string) => { handleQueryChange({ user_id: val || '*' }); }; const renderEmpty = (type?: string) => { if (type !== 'Table') return; return ( } filters={_.omit(queryParams, ['sort_by'])} noFoundText={intl.formatMessage({ id: 'noresult.keys.nofound' })} title={intl.formatMessage({ id: 'noresult.keys.title' })} subTitle={intl.formatMessage({ id: 'noresult.keys.subTitle' })} onClick={handleAddKey} buttonText={intl.formatMessage({ id: 'noresult.button.add' })} > ); }; const columns = useKeysColumns({ handleSelect: onSelect, sortOrder, showCreator: canSeeAllKeys, configActions, onConfigAction: handleConfigAction }); return ( <>
{/* One mount per registered action. Each mount calls its entry's `useCreate` (single hook per component, so iterating the plugin list doesn't violate the Rules of Hooks), renders the form, and registers its controller so dropdown clicks can dispatch to it. */} {configActions.map((action) => ( ))} ); }; export default APIKeys;