Move the model-route access modal off the deprecated allowed_users policy/field onto the unified allowed_principals + principals surface, persisting everything through a single /access POST. - "specific users" radio now uses allowed_principals; a returned legacy allowed_users value is normalized so existing routes still select it. - derive the picker's selection and the full grant set from `principals` in GET /access (fall back to legacy `items` if a backend doesn't return principals yet). - save as `principals`: the principal-based override sends its staged set; the user picker maps its selection to USER-kind grants and preserves any non-user grants from the snapshot (no longer sends `users`). - guard saving before the GET seeds principals (would wipe grants); share the ALLOWED_PRINCIPALS_POLICY constant. - AccessControlFormData: `users` optional, add `principals`.
541 lines
17 KiB
TypeScript
541 lines
17 KiB
TypeScript
import { PageAction } from '@/config';
|
|
import { PageActionType } from '@/config/types';
|
|
import { RouteItem } from '@/pages/model-routes/config/types';
|
|
import { queryUserDirectory } from '@/pages/users/apis';
|
|
import { getGPUStackPlugin } from '@/plugins';
|
|
import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
|
import {
|
|
AlertBlockInfo,
|
|
TooltipList,
|
|
Transfer as TransferInner
|
|
} from '@gpustack/core-ui';
|
|
import { useIntl } from '@umijs/max';
|
|
import {
|
|
Checkbox,
|
|
Dropdown,
|
|
DropdownProps,
|
|
Empty,
|
|
Form,
|
|
Radio,
|
|
RadioChangeEvent,
|
|
Tooltip
|
|
} from 'antd';
|
|
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react';
|
|
import styled from 'styled-components';
|
|
import { queryModelAccessUserList } from '../../apis';
|
|
import { AccessControlFormData } from '../../config/types';
|
|
|
|
type TransferKey = string | number | bigint;
|
|
|
|
// The "specific users" policy is now ALLOWED_PRINCIPALS with a
|
|
// user-only grant list — the same value the principal-based override
|
|
// (when a plugin provides one) uses, so the two interoperate.
|
|
// `allowed_users` is the deprecated value released in v2.1.x; normalize
|
|
// it so legacy routes still select the "specific users" radio (they
|
|
// converge to ALLOWED_PRINCIPALS on save).
|
|
export const ALLOWED_PRINCIPALS_POLICY = 'allowed_principals';
|
|
const normalizeAccessPolicy = (p?: string) =>
|
|
p === 'allowed_users' ? ALLOWED_PRINCIPALS_POLICY : p;
|
|
|
|
const buildAccessScopeTips = (
|
|
override?: AllowedUsersOverride,
|
|
prepended: PrependedPolicy[] = []
|
|
) => [
|
|
...prepended.map((p) => ({
|
|
title: { text: p.labelId, locale: true },
|
|
tips: p.tipsId ?? p.labelId
|
|
})),
|
|
{
|
|
title: {
|
|
text: 'models.accessSettings.authed',
|
|
locale: true
|
|
},
|
|
tips: 'models.accessSettings.authed.tips'
|
|
},
|
|
{
|
|
title: {
|
|
text: override?.labelId ?? 'models.accessSettings.allowedUsers',
|
|
locale: true
|
|
},
|
|
tips: override?.tipsId ?? 'models.accessSettings.allowedUsers.tips'
|
|
},
|
|
{
|
|
title: {
|
|
text: 'models.accessSettings.public',
|
|
locale: true
|
|
},
|
|
tips: 'models.accessSettings.public.desc'
|
|
}
|
|
];
|
|
|
|
const Label = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-weight: 500;
|
|
margin-block: 8px 12px;
|
|
font-size: 14px;
|
|
color: var(--ant-color-text-tertiary);
|
|
`;
|
|
|
|
interface AccessControlFormProps {
|
|
action: PageActionType;
|
|
currentData?: RouteItem | null;
|
|
onFinish: (values: AccessControlFormData) => void;
|
|
onValuesChange?: (changedValues: any, allValues: any) => void;
|
|
}
|
|
|
|
// Extension seam: an enterprise plugin can substitute the legacy
|
|
// `allowed_users` policy entry with a different one (e.g. the
|
|
// principal-based ALLOWED_PRINCIPALS surface). When the override is
|
|
// present, the OSS `allowed_users` radio option is swapped out and
|
|
// the override's ``Field`` component is rendered when that policy is
|
|
// active. Without a plugin the OSS form is unchanged.
|
|
type AllowedUsersOverride = {
|
|
policyValue: string;
|
|
labelId: string;
|
|
tipsId?: string;
|
|
Field: React.ComponentType<{
|
|
form: any;
|
|
routeId?: number;
|
|
action: PageActionType;
|
|
}>;
|
|
};
|
|
|
|
// Extra policy entries prepended to the radio list (rendered before
|
|
// `authed`). Each entry can optionally bring a `Field` that renders
|
|
// when its policy is selected — leave it out for plain "scope"
|
|
// policies that need no extra config (e.g. ORG).
|
|
type PrependedPolicy = {
|
|
policyValue: string;
|
|
labelId: string;
|
|
tipsId?: string;
|
|
Field?: React.ComponentType<{
|
|
form: any;
|
|
routeId?: number;
|
|
action: PageActionType;
|
|
}>;
|
|
};
|
|
|
|
const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
|
|
const { action, currentData, onFinish, onValuesChange } = props;
|
|
const intl = useIntl();
|
|
const [form] = Form.useForm();
|
|
const accessPolicy = Form.useWatch('access_policy', form);
|
|
const pluginAccessControl = getGPUStackPlugin()?.accessControl;
|
|
const allowedUsersOverride: AllowedUsersOverride | undefined =
|
|
pluginAccessControl?.allowedUsersOverride;
|
|
const overridePolicyValue = allowedUsersOverride?.policyValue;
|
|
const prependedPolicies: PrependedPolicy[] =
|
|
pluginAccessControl?.prependedPolicies ?? [];
|
|
const resolveCreateDefault: (() => string | undefined) | undefined =
|
|
pluginAccessControl?.resolveCreateDefault;
|
|
const activePrependedPolicy = prependedPolicies.find(
|
|
(p) => p.policyValue === accessPolicy
|
|
);
|
|
const [targetKeys, setTargetKeys] = useState<TransferKey[]>([]);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [userList, setUserList] = useState<
|
|
{ title: string; key: number; is_admin: boolean; is_active: boolean }[]
|
|
>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [filterInUsers, setFilterInUsers] = useState<Set<string>>(new Set());
|
|
const [queryParams, setQueryParams] = useState<Global.SearchParams>({
|
|
page: -1
|
|
});
|
|
const formDataCacheRef = useRef<AccessControlFormData | null>(null);
|
|
|
|
const dataList = useMemo(() => {
|
|
if (filterInUsers.size === 0) {
|
|
return userList.filter((user) => {
|
|
return !user.is_admin && user.is_active;
|
|
});
|
|
}
|
|
return userList.filter((user) => {
|
|
const isAdmin = user.is_admin;
|
|
const isActive = user.is_active;
|
|
if (!filterInUsers.has('admin') && isAdmin) {
|
|
return false;
|
|
}
|
|
if (!filterInUsers.has('inactive') && !isActive) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [userList, filterInUsers]);
|
|
|
|
const getUserList = async (query: Global.SearchParams) => {
|
|
try {
|
|
const res = await queryUserDirectory(query);
|
|
const options = res.items.map((item) => ({
|
|
title: item.username,
|
|
key: item.id,
|
|
is_admin: item.is_admin as boolean,
|
|
is_active: item.is_active as boolean
|
|
}));
|
|
setTotalPages(res.pagination.totalPage);
|
|
setUserList(options);
|
|
return options;
|
|
} catch (error) {
|
|
setUserList([]);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const handleOnChange = async (
|
|
nextTargetKeys: TransferKey[],
|
|
direction: string,
|
|
removeKeys: TransferKey[]
|
|
) => {
|
|
setTargetKeys(nextTargetKeys);
|
|
const users = nextTargetKeys.map((key) => ({ id: key }));
|
|
form.setFieldsValue({ users });
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 100);
|
|
});
|
|
onValuesChange?.({ users }, form.getFieldsValue());
|
|
};
|
|
|
|
const onSearch = (dir: 'left' | 'right', value: string) => {
|
|
console.log('search:', dir, value);
|
|
};
|
|
|
|
const handleCheck = (e: any, type: string) => {
|
|
const newSet = new Set(filterInUsers);
|
|
if (e.target.checked) {
|
|
newSet.add(type);
|
|
} else {
|
|
newSet.delete(type);
|
|
}
|
|
setFilterInUsers(newSet);
|
|
};
|
|
|
|
const handleOpenChange: DropdownProps['onOpenChange'] = (nextOpen, info) => {
|
|
if (info.source === 'trigger' || nextOpen) {
|
|
setOpen(nextOpen);
|
|
}
|
|
};
|
|
|
|
const handleOnPolicyChange = async (e: RadioChangeEvent) => {
|
|
console.log('policy changed:', e.target.value);
|
|
const policy = e.target.value;
|
|
if (policy === ALLOWED_PRINCIPALS_POLICY) {
|
|
form.setFieldsValue({ users: formDataCacheRef.current?.users || [] });
|
|
} else {
|
|
formDataCacheRef.current = {
|
|
access_policy: policy,
|
|
users: form.getFieldValue('users') || []
|
|
};
|
|
}
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 100);
|
|
});
|
|
onValuesChange?.({ access_policy: policy }, form.getFieldsValue());
|
|
};
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
submit: () => {
|
|
form.submit();
|
|
},
|
|
setFieldsValue: (values: Partial<AccessControlFormData>) => {
|
|
form.setFieldsValue(values);
|
|
},
|
|
getFieldsValue: () => {
|
|
return form.getFieldsValue();
|
|
},
|
|
resetFields: () => {
|
|
form.resetFields();
|
|
}
|
|
}));
|
|
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
const allusers = await getUserList(queryParams);
|
|
const userMap = new Map(allusers.map((u) => [u.key, u]));
|
|
|
|
if (currentData?.id) {
|
|
// Seed the radio from the parent's snapshot so the form isn't
|
|
// momentarily unselected; the GET below replaces it with the
|
|
// server's authoritative value, which is what survives a save
|
|
// when the parent list hasn't been refreshed.
|
|
form.setFieldsValue({
|
|
access_policy: normalizeAccessPolicy(currentData?.access_policy)
|
|
});
|
|
queryModelAccessUserList(currentData.id).then((res) => {
|
|
// Fall back to the legacy `items` (USER-only) field when an
|
|
// older backend doesn't return `principals` yet.
|
|
const principals =
|
|
res.principals ??
|
|
res.items?.map((item) => ({
|
|
principal_type: 'user',
|
|
principal_id: item.id
|
|
})) ??
|
|
[];
|
|
// Derive the user picker's selection from the unified
|
|
// `principals` set (USER-kind subset), not the deprecated
|
|
// `items` field. `principals` is also kept whole so a save can
|
|
// preserve any non-user grants it doesn't manage.
|
|
const userKeys = principals
|
|
.filter((p) => p.principal_type === 'user')
|
|
.map((p) => p.principal_id);
|
|
setTargetKeys(userKeys);
|
|
|
|
let hasAdmin = false;
|
|
let hasInactive = false;
|
|
|
|
for (const key of userKeys) {
|
|
const user = userMap.get(key);
|
|
if (!user) continue;
|
|
if (user.is_admin) hasAdmin = true;
|
|
if (!user.is_active) hasInactive = true;
|
|
if (hasAdmin && hasInactive) break;
|
|
}
|
|
|
|
const filterSet = new Set<string>();
|
|
if (hasAdmin) filterSet.add('admin');
|
|
if (hasInactive) filterSet.add('inactive');
|
|
|
|
setFilterInUsers(filterSet);
|
|
|
|
form.setFieldsValue({
|
|
access_policy: normalizeAccessPolicy(
|
|
res.access_policy ?? currentData.access_policy
|
|
),
|
|
users: userKeys.map((id) => ({ id })),
|
|
// Keep the full grant set: read by the principal-based
|
|
// override Field, and used on save to preserve non-user
|
|
// grants when the user picker submits `principals`.
|
|
principals
|
|
});
|
|
});
|
|
} else {
|
|
setTargetKeys([]);
|
|
form.setFieldsValue({ users: [] });
|
|
}
|
|
};
|
|
init();
|
|
}, [currentData?.id]);
|
|
|
|
const renderFilterDropdown = () => {
|
|
return (
|
|
<Dropdown
|
|
open={open}
|
|
onOpenChange={handleOpenChange}
|
|
key="filter-dropdown"
|
|
menu={{
|
|
items: [
|
|
{
|
|
label: (
|
|
<Checkbox
|
|
checked={filterInUsers.has('admin')}
|
|
onChange={(e: any) => handleCheck(e, 'admin')}
|
|
>
|
|
{intl.formatMessage({ id: 'models.table.admin' })}
|
|
</Checkbox>
|
|
),
|
|
key: '0'
|
|
},
|
|
{
|
|
label: (
|
|
<Checkbox
|
|
checked={filterInUsers.has('inactive')}
|
|
onChange={(e: any) => handleCheck(e, 'inactive')}
|
|
>
|
|
{intl.formatMessage({ id: 'users.status.inactiveAccount' })}
|
|
</Checkbox>
|
|
),
|
|
key: '1'
|
|
}
|
|
]
|
|
}}
|
|
>
|
|
<span>
|
|
<span className="m-r-8" style={{ cursor: 'default' }}>
|
|
{intl.formatMessage({
|
|
id: 'models.accessControlModal.includeusers'
|
|
})}
|
|
</span>
|
|
<DownOutlined style={{ fontSize: 12 }} />
|
|
</span>
|
|
</Dropdown>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Form
|
|
form={form}
|
|
// Submit the full field store (getFieldsValue(true)), not just the
|
|
// registered fields onFinish would pass: the principal-based
|
|
// override Field manages `principals` via setFieldsValue without a
|
|
// registered Form.Item, so it would otherwise be dropped on save.
|
|
onFinish={() => onFinish(form.getFieldsValue(true))}
|
|
preserve={true}
|
|
clearOnDestroy={true}
|
|
scrollToFirstError={true}
|
|
initialValues={{
|
|
users: [],
|
|
// For create the plugin (if registered) may supply a context-
|
|
// sensitive default — e.g. routes in a non-platform Org
|
|
// default to the Org-scoped policy. Fall back to `authed`
|
|
// (the OSS default) if the plugin doesn't return a value.
|
|
access_policy:
|
|
action === PageAction.CREATE
|
|
? (resolveCreateDefault?.() ?? 'authed')
|
|
: undefined
|
|
}}
|
|
>
|
|
<Label>
|
|
{intl.formatMessage({ id: 'models.table.accessScope' })}
|
|
<Tooltip
|
|
title={
|
|
<TooltipList
|
|
list={buildAccessScopeTips(
|
|
allowedUsersOverride,
|
|
prependedPolicies
|
|
)}
|
|
></TooltipList>
|
|
}
|
|
>
|
|
<QuestionCircleOutlined />
|
|
</Tooltip>
|
|
</Label>
|
|
|
|
<Form.Item<AccessControlFormData> name="access_policy" noStyle>
|
|
<Radio.Group
|
|
onChange={handleOnPolicyChange}
|
|
style={{ marginBottom: 12 }}
|
|
options={[
|
|
...prependedPolicies.map((p) => ({
|
|
label: intl.formatMessage({ id: p.labelId }),
|
|
value: p.policyValue
|
|
})),
|
|
{
|
|
label: intl.formatMessage({ id: 'models.accessSettings.authed' }),
|
|
value: 'authed'
|
|
},
|
|
allowedUsersOverride
|
|
? {
|
|
label: intl.formatMessage({
|
|
id: allowedUsersOverride.labelId
|
|
}),
|
|
value: allowedUsersOverride.policyValue
|
|
}
|
|
: {
|
|
label: intl.formatMessage({
|
|
id: 'models.accessSettings.allowedUsers'
|
|
}),
|
|
value: ALLOWED_PRINCIPALS_POLICY
|
|
},
|
|
{
|
|
label: intl.formatMessage({
|
|
id: 'models.accessSettings.public'
|
|
}),
|
|
value: 'public'
|
|
}
|
|
]}
|
|
></Radio.Group>
|
|
</Form.Item>
|
|
{accessPolicy === 'public' && (
|
|
<div style={{ marginBlock: '16px 12px' }}>
|
|
<AlertBlockInfo
|
|
type="danger"
|
|
message={intl.formatMessage({
|
|
id: 'models.accessSettings.public.tips'
|
|
})}
|
|
></AlertBlockInfo>
|
|
</div>
|
|
)}
|
|
{activePrependedPolicy?.Field && (
|
|
<activePrependedPolicy.Field
|
|
form={form}
|
|
routeId={currentData?.id}
|
|
action={action}
|
|
/>
|
|
)}
|
|
{allowedUsersOverride && accessPolicy === overridePolicyValue && (
|
|
<allowedUsersOverride.Field
|
|
form={form}
|
|
routeId={currentData?.id}
|
|
action={action}
|
|
/>
|
|
)}
|
|
{!allowedUsersOverride && accessPolicy === ALLOWED_PRINCIPALS_POLICY && (
|
|
<>
|
|
<Label>
|
|
{intl.formatMessage({ id: 'models.table.userSelection' })}
|
|
<Tooltip
|
|
title={intl.formatMessage({
|
|
id: 'models.table.userSelection.tips'
|
|
})}
|
|
>
|
|
<QuestionCircleOutlined />
|
|
</Tooltip>
|
|
</Label>
|
|
<Form.Item<AccessControlFormData> name="users">
|
|
<TransferInner
|
|
dataSource={dataList}
|
|
targetKeys={targetKeys}
|
|
pagination={false}
|
|
titles={[
|
|
renderFilterDropdown(),
|
|
intl.formatMessage({
|
|
id: 'models.table.users.selected'
|
|
})
|
|
]}
|
|
locale={{
|
|
notFoundContent: [
|
|
<Empty
|
|
key="all"
|
|
description={intl.formatMessage({
|
|
id: 'models.table.nouserFound'
|
|
})}
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
/>,
|
|
<Empty
|
|
key="selected"
|
|
description={intl.formatMessage({
|
|
id: 'models.table.noselected'
|
|
})}
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
/>
|
|
]
|
|
}}
|
|
showSearch={{
|
|
placeholder: intl.formatMessage({
|
|
id: 'common.filter.name'
|
|
})
|
|
}}
|
|
filterOption={(inputValue, item) =>
|
|
item.title.toLowerCase().includes(inputValue.toLowerCase())
|
|
}
|
|
render={(item) => (
|
|
<span className="flex-center gap-4">
|
|
<span>{item.title}</span>
|
|
<span className="text-tertiary">
|
|
{!item.is_active
|
|
? `[${intl.formatMessage({ id: 'users.status.inactive' })}]`
|
|
: item.is_admin
|
|
? `[${intl.formatMessage({ id: 'models.table.admin' })}]`
|
|
: ''}
|
|
</span>
|
|
</span>
|
|
)}
|
|
onSearch={onSearch}
|
|
onChange={handleOnChange}
|
|
/>
|
|
</Form.Item>
|
|
</>
|
|
)}
|
|
</Form>
|
|
);
|
|
});
|
|
|
|
export default AccessControlForm;
|