The "Filter by creator" dropdown was listing every user in the system. For an Org owner viewing an Org-scoped key list, picking a user outside the Org always produced an empty result. Pass scope=current_org to `/user-directory` so the picker matches the list's actual scope. BE drops the param when the request has no Org context, so callers without an Org are unchanged.
281 lines
8.5 KiB
TypeScript
281 lines
8.5 KiB
TypeScript
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<ListItem>({
|
|
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<Record<string, APIKeyConfigActionController>>(
|
|
{}
|
|
);
|
|
const registerController = useCallback(
|
|
(key: string, controller: APIKeyConfigActionController) => {
|
|
controllersRef.current[key] = controller;
|
|
},
|
|
[]
|
|
);
|
|
|
|
const [openAddModal, setOpenAddModal] = useState<{
|
|
open: boolean;
|
|
action: PageActionType;
|
|
title: string;
|
|
currentData?: Partial<ListItem> | 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 (
|
|
<NoResult
|
|
loading={dataSource.loading}
|
|
loadend={dataSource.loadend}
|
|
dataSource={dataSource.dataList}
|
|
image={<IconFont type="icon-key" />}
|
|
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' })}
|
|
></NoResult>
|
|
);
|
|
};
|
|
|
|
const columns = useKeysColumns({
|
|
handleSelect: onSelect,
|
|
sortOrder,
|
|
showCreator: canSeeAllKeys,
|
|
configActions,
|
|
onConfigAction: handleConfigAction
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<PageBox>
|
|
<FilterBar
|
|
marginBottom={22}
|
|
marginTop={30}
|
|
showSelect={canSeeAllKeys}
|
|
selectOptions={userList}
|
|
select={{ showSearch: { optionFilterProp: 'label' } }}
|
|
selectHolder={intl.formatMessage({ id: 'common.filter.byCreator' })}
|
|
buttonText={intl.formatMessage({ id: 'apikeys.button.create' })}
|
|
handleSearch={handleSearch}
|
|
handleDeleteByBatch={handleDeleteBatch}
|
|
handleClickPrimary={handleAddKey}
|
|
handleSelectChange={handleUserChange}
|
|
handleInputChange={handleNameChange}
|
|
rowSelection={rowSelection}
|
|
widths={{ input: 300 }}
|
|
></FilterBar>
|
|
<ConfigProvider renderEmpty={renderEmpty}>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={dataSource.dataList}
|
|
rowSelection={rowSelection}
|
|
loading={{
|
|
spinning: dataSource.loading,
|
|
size: 'middle'
|
|
}}
|
|
sortDirections={TABLE_SORT_DIRECTIONS}
|
|
showSorterTooltip={false}
|
|
rowKey="id"
|
|
onChange={handleTableChange}
|
|
pagination={{
|
|
size: 'middle',
|
|
showSizeChanger: true,
|
|
pageSize: queryParams.perPage,
|
|
current: queryParams.page,
|
|
total: dataSource.total,
|
|
hideOnSinglePage: queryParams.perPage === 10,
|
|
onChange: handlePageChange
|
|
}}
|
|
></Table>
|
|
</ConfigProvider>
|
|
</PageBox>
|
|
<AddAPIKeyModal
|
|
open={openAddModal.open}
|
|
action={openAddModal.action}
|
|
title={openAddModal.title}
|
|
currentData={openAddModal.currentData}
|
|
onCancel={handleModalCancel}
|
|
onOk={handleModalOk}
|
|
></AddAPIKeyModal>
|
|
<DeleteModal ref={modalRef}></DeleteModal>
|
|
{/* 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) => (
|
|
<APIKeyConfigActionMount
|
|
key={action.key}
|
|
action={action}
|
|
registerController={registerController}
|
|
onOk={fetchData}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default APIKeys;
|