fix: scope storage type picker to the target organization
The storage create form fetched persistent-volume types once with no tenant scope, so in the platform-admin "All" view the dropdown listed every org's types and never re-scoped when the create-scope picker retargeted the form — letting an org with no cluster-access grant pick another org's types. The inline "add storage" sub-drawer in the instance create form had the same gap plus a redundant org picker that could retarget the storage away from the instance's org. Both surfaces now pin the storage-type list to the chosen org (via the X-Organization-Id header); the sub-drawer inherits the instance's scope and hides its own picker. Page/table listings stay unscoped so every row's type label still renders.
This commit is contained in:
@@ -10,12 +10,18 @@ import useOverlayLayout from '../hooks/use-overlay-layout';
|
||||
|
||||
interface StorageOverlayProps {
|
||||
open: boolean;
|
||||
// Org the surrounding instance create form targets (platform admin "All"
|
||||
// view). The storage inherits this scope, so the type list is pinned to
|
||||
// it — the picker only offers types that org can reference. Undefined
|
||||
// when there's no create-scope picker (the ambient org context applies).
|
||||
scopeOrgId?: number | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: StorageFormData) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
||||
open,
|
||||
scopeOrgId,
|
||||
onCancel,
|
||||
onSubmit
|
||||
}) => {
|
||||
@@ -28,9 +34,14 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchStorageClass({ page: -1 });
|
||||
fetchStorageClass(
|
||||
{ page: -1 },
|
||||
scopeOrgId != null
|
||||
? { headers: { 'X-Organization-Id': String(scopeOrgId) } }
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, scopeOrgId]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.current?.submit();
|
||||
@@ -75,6 +86,7 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
||||
ref={formRef}
|
||||
action={PageAction.CREATE}
|
||||
open={open}
|
||||
showOrgScope={false}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</FormContext.Provider>
|
||||
|
||||
@@ -30,6 +30,10 @@ const StorageVolume = ({
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const storageMode = Form.useWatch('storageMode', form);
|
||||
// Owned by the instance create-scope picker (platform admin "All" view).
|
||||
// A storage added inline belongs to the same org as the instance, so pass
|
||||
// it to the overlay to scope the storage-type list to that org.
|
||||
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||
const { fetchData: createStorage } = useCreateStorage();
|
||||
const { detailData: storageData, fetchData: fetchStorage } =
|
||||
useQueryStorage();
|
||||
@@ -232,6 +236,7 @@ const StorageVolume = ({
|
||||
|
||||
<StorageOverlay
|
||||
open={overlayOpen}
|
||||
scopeOrgId={scopeOrgId}
|
||||
onCancel={() => setOverlayOpen(false)}
|
||||
onSubmit={handleCreateStorage}
|
||||
/>
|
||||
|
||||
@@ -51,6 +51,11 @@ export async function queryStorageClass(
|
||||
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
|
||||
method: 'GET',
|
||||
params,
|
||||
// `headers` lets the create form pin the request to a specific org so
|
||||
// the picker only offers storage types that org can reference. GETs
|
||||
// otherwise inherit the ambient org context, which is empty in the
|
||||
// platform-admin "All" view and would list every org's types.
|
||||
headers: options?.headers,
|
||||
cancelToken: options?.token
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import { FormDrawer, ModalFooter } from '@gpustack/core-ui';
|
||||
import { useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { FormContext } from '../config/form-context';
|
||||
import { FormData, ListItem } from '../config/types';
|
||||
import GPUServiceStorageForm from '../forms';
|
||||
import useQueryStorageClass from '../services/use-query-storage-class';
|
||||
|
||||
type AddModalProps = {
|
||||
title: string;
|
||||
@@ -13,7 +14,6 @@ type AddModalProps = {
|
||||
onOk: (values: FormData) => void;
|
||||
data?: ListItem | null;
|
||||
onCancel: () => void;
|
||||
storageClassList: Global.BaseOption<string>[];
|
||||
};
|
||||
|
||||
const AddModal: React.FC<AddModalProps> = ({
|
||||
@@ -22,11 +22,33 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
open,
|
||||
onOk,
|
||||
data,
|
||||
onCancel,
|
||||
storageClassList
|
||||
onCancel
|
||||
}) => {
|
||||
const form = useRef<any>(null);
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
// The dropdown gets its own storage-type list, scoped to the org the
|
||||
// create-scope picker targets — distinct from the page-level list, which
|
||||
// stays unscoped so the table can label every org's rows.
|
||||
const { storageClassList, fetchData: fetchStorageClass } =
|
||||
useQueryStorageClass();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchStorageClass({ page: -1 });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Platform admin picked a target org in the create-scope slot: reload the
|
||||
// storage-type list pinned to that org so the dropdown only offers types
|
||||
// the org can reference (its own plus any reachable via cluster access).
|
||||
const handleScopeChange = (orgId?: number | null) => {
|
||||
fetchStorageClass(
|
||||
{ page: -1 },
|
||||
orgId != null
|
||||
? { headers: { 'X-Organization-Id': String(orgId) } }
|
||||
: undefined
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
guard(() => form.current?.submit());
|
||||
@@ -70,6 +92,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
ref={form}
|
||||
action={action}
|
||||
currentData={data}
|
||||
onScopeChange={handleScopeChange}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={release}
|
||||
open={open}
|
||||
|
||||
@@ -12,7 +12,23 @@ import { useContext } from 'react';
|
||||
import { FormContext } from '../config/form-context';
|
||||
import { FormData } from '../config/types';
|
||||
|
||||
const Basic = ({ action, open }: { action: string; open: boolean }) => {
|
||||
const Basic = ({
|
||||
action,
|
||||
open,
|
||||
showOrgScope = true,
|
||||
onOrgScopeChange
|
||||
}: {
|
||||
action: string;
|
||||
open: boolean;
|
||||
// Hosts that already fix the tenant scope elsewhere (e.g. the instance
|
||||
// create form, which owns the org picker) hide this slot so the storage
|
||||
// is created in the surrounding scope rather than a second, conflicting
|
||||
// one.
|
||||
showOrgScope?: boolean;
|
||||
// Fired on a user selection in the create-scope picker so the host can
|
||||
// reload the org-scoped storage-type list.
|
||||
onOrgScopeChange?: (orgId: number | null | undefined) => void;
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const { storageClassList } = useContext(FormContext);
|
||||
@@ -44,7 +60,12 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => {
|
||||
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
{showOrgScope && (
|
||||
<PluginExtraFields
|
||||
name="CreateOrgScopeField"
|
||||
context={{ action, onChange: onOrgScopeChange }}
|
||||
/>
|
||||
)}
|
||||
<Flex gap={16}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item<FormData>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Form } from 'antd';
|
||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { FormData, ListItem } from '../config/types';
|
||||
import Basic from './basic';
|
||||
|
||||
@@ -10,14 +11,67 @@ interface StorageFormProps {
|
||||
open: boolean;
|
||||
action: PageActionType;
|
||||
currentData?: ListItem | null;
|
||||
// Hide the create-scope org picker when the host already fixes the
|
||||
// tenant scope (e.g. the instance create form). Defaults to shown.
|
||||
showOrgScope?: boolean;
|
||||
// Fired when the create-scope picker (platform admin "All" view)
|
||||
// retargets the form to another org. Only emitted on genuine changes,
|
||||
// never on the initial mount, and never in builds where the picker
|
||||
// isn't mounted (the watched field stays undefined). Lets the parent
|
||||
// reload the org-scoped storage-type options.
|
||||
onScopeChange?: (orgId: number | null | undefined) => void;
|
||||
onFinish: (values: FormData) => Promise<void>;
|
||||
onFinishFailed?: (errorInfo: any) => void;
|
||||
}
|
||||
|
||||
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
|
||||
(props, ref) => {
|
||||
const { action, currentData, open, onFinish, onFinishFailed } = props;
|
||||
const {
|
||||
action,
|
||||
currentData,
|
||||
open,
|
||||
showOrgScope = true,
|
||||
onScopeChange,
|
||||
onFinish,
|
||||
onFinishFailed
|
||||
} = props;
|
||||
const [form] = Form.useForm<FormData>();
|
||||
// `organization_id` is owned by the create-scope picker slot; it only
|
||||
// exists/changes when a platform admin retargets the form. Watch it so
|
||||
// the initial/default scope can be propagated once the picker resolves it.
|
||||
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||
const scopeInitRef = useRef(true);
|
||||
|
||||
// Stable wrapper so the effect / change handler always call the latest
|
||||
// callback without re-subscribing on the parent's fn identity.
|
||||
const orgScope = useMemoizedFn((orgId?: number | null) => {
|
||||
onScopeChange?.(orgId);
|
||||
});
|
||||
|
||||
// Genuine retarget by the create-scope picker: drop the stale type pick
|
||||
// (it may name a type the newly chosen org can't reference) and reload the
|
||||
// org-scoped list. Wired to the picker's own onChange, so it fires only on
|
||||
// a user selection — never on the picker's programmatic default.
|
||||
const handleOrgScopeChange = useMemoizedFn((orgId?: number | null) => {
|
||||
scopeInitRef.current = false;
|
||||
form.setFieldValue(['spec', 'type'], undefined);
|
||||
orgScope(orgId ?? null);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Re-arm the initial-scope propagation for the next open.
|
||||
scopeInitRef.current = true;
|
||||
return;
|
||||
}
|
||||
// Propagate the initial/default scope once the picker resolves it so the
|
||||
// parent loads the org-scoped type list, leaving any pre-filled type
|
||||
// intact. User-driven retargets go through handleOrgScopeChange instead.
|
||||
if (scopeInitRef.current && scopeOrgId != null) {
|
||||
scopeInitRef.current = false;
|
||||
orgScope(scopeOrgId);
|
||||
}
|
||||
}, [open, scopeOrgId, orgScope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -56,7 +110,12 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
|
||||
preserve={false}
|
||||
initialValues={{}}
|
||||
>
|
||||
<Basic action={action} open={open} />
|
||||
<Basic
|
||||
action={action}
|
||||
open={open}
|
||||
showOrgScope={showOrgScope}
|
||||
onOrgScopeChange={handleOrgScopeChange}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,7 +177,6 @@ const GPUServiceStorage: React.FC = () => {
|
||||
action={openStorageModalStatus.action}
|
||||
title={openStorageModalStatus.title}
|
||||
data={openStorageModalStatus.currentData}
|
||||
storageClassList={storageClassList}
|
||||
onCancel={closeStorageModal}
|
||||
onOk={handleModalOk}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user