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:
gitlawr
2026-07-08 16:31:53 +08:00
committed by jialin
parent c025257fea
commit 644aba3fc2
7 changed files with 136 additions and 12 deletions
@@ -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}
+23 -2
View File
@@ -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>
+62 -3
View File
@@ -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>
);
}
-1
View File
@@ -177,7 +177,6 @@ const GPUServiceStorage: React.FC = () => {
action={openStorageModalStatus.action}
title={openStorageModalStatus.title}
data={openStorageModalStatus.currentData}
storageClassList={storageClassList}
onCancel={closeStorageModal}
onOk={handleModalOk}
/>