feat: access-control modal seam to swap the allowed_users surface

The OSS Access Settings form hardcodes the `allowed_users` policy
(per-user explicit grants via the legacy transfer widget). Multi-
tenancy needs a different surface that grants by Org / Group / User
principals via the `/v2/model-routes/{id}/principals` endpoints, and
the enterprise plugin will ship that — but the OSS form should stay
visually unchanged when no plugin is loaded.

Add a plugin extension point on the form: if a registered plugin
exposes `accessControl.allowedUsersOverride: { policyValue, labelId,
tipsId?, Field }`, the form replaces the `allowed_users` radio entry
with the plugin's labelled option, swaps the access-scope tooltip
copy, and renders `<Field form routeId action />` for the override
policy. Without a plugin the form's radio / tooltip / content path
are unchanged.

`handleOnFinish` continues to send `users` only for `allowed_users`;
any other policy (including the plugin's value) goes through with
an empty users list, leaving the plugin's Field to manage its own
principal CRUD inline.
This commit is contained in:
gitlawr
2026-05-11 19:30:21 +08:00
committed by jialin
parent 93fa277bdd
commit f37fad11b1
2 changed files with 60 additions and 12 deletions
@@ -2,6 +2,7 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { RouteItem } from '@/pages/model-routes/config/types'; import { RouteItem } from '@/pages/model-routes/config/types';
import { queryUserDirectory } from '@/pages/users/apis'; import { queryUserDirectory } from '@/pages/users/apis';
import { getGPUStackPlugin } from '@/plugins';
import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { import {
AlertBlockInfo, AlertBlockInfo,
@@ -33,7 +34,7 @@ import { AccessControlFormData } from '../../config/types';
type TransferKey = string | number | bigint; type TransferKey = string | number | bigint;
const accessScopeTips = [ const buildAccessScopeTips = (override?: AllowedUsersOverride) => [
{ {
title: { title: {
text: 'models.accessSettings.authed', text: 'models.accessSettings.authed',
@@ -43,10 +44,10 @@ const accessScopeTips = [
}, },
{ {
title: { title: {
text: 'models.accessSettings.allowedUsers', text: override?.labelId ?? 'models.accessSettings.allowedUsers',
locale: true locale: true
}, },
tips: 'models.accessSettings.allowedUsers.tips' tips: override?.tipsId ?? 'models.accessSettings.allowedUsers.tips'
}, },
{ {
title: { title: {
@@ -74,11 +75,31 @@ interface AccessControlFormProps {
onValuesChange?: (changedValues: any, allValues: any) => 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;
}>;
};
const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => { const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
const { action, currentData, onFinish, onValuesChange } = props; const { action, currentData, onFinish, onValuesChange } = props;
const intl = useIntl(); const intl = useIntl();
const [form] = Form.useForm(); const [form] = Form.useForm();
const accessPolicy = Form.useWatch('access_policy', form); const accessPolicy = Form.useWatch('access_policy', form);
const allowedUsersOverride: AllowedUsersOverride | undefined =
getGPUStackPlugin()?.accessControl?.allowedUsersOverride;
const overridePolicyValue = allowedUsersOverride?.policyValue;
const [targetKeys, setTargetKeys] = useState<TransferKey[]>([]); const [targetKeys, setTargetKeys] = useState<TransferKey[]>([]);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
const [userList, setUserList] = useState< const [userList, setUserList] = useState<
@@ -296,7 +317,13 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
> >
<Label> <Label>
{intl.formatMessage({ id: 'models.table.accessScope' })} {intl.formatMessage({ id: 'models.table.accessScope' })}
<Tooltip title={<TooltipList list={accessScopeTips}></TooltipList>}> <Tooltip
title={
<TooltipList
list={buildAccessScopeTips(allowedUsersOverride)}
></TooltipList>
}
>
<QuestionCircleOutlined /> <QuestionCircleOutlined />
</Tooltip> </Tooltip>
</Label> </Label>
@@ -310,12 +337,19 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
label: intl.formatMessage({ id: 'models.accessSettings.authed' }), label: intl.formatMessage({ id: 'models.accessSettings.authed' }),
value: 'authed' value: 'authed'
}, },
{ allowedUsersOverride
label: intl.formatMessage({ ? {
id: 'models.accessSettings.allowedUsers' label: intl.formatMessage({
}), id: allowedUsersOverride.labelId
value: 'allowed_users' }),
}, value: allowedUsersOverride.policyValue
}
: {
label: intl.formatMessage({
id: 'models.accessSettings.allowedUsers'
}),
value: 'allowed_users'
},
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.public' id: 'models.accessSettings.public'
@@ -335,7 +369,15 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
></AlertBlockInfo> ></AlertBlockInfo>
</div> </div>
)} )}
{accessPolicy === 'allowed_users' && ( {allowedUsersOverride &&
accessPolicy === overridePolicyValue && (
<allowedUsersOverride.Field
form={form}
routeId={currentData?.id}
action={action}
/>
)}
{!allowedUsersOverride && accessPolicy === 'allowed_users' && (
<> <>
<Label> <Label>
{intl.formatMessage({ id: 'models.table.userSelection' })} {intl.formatMessage({ id: 'models.table.userSelection' })}
@@ -23,8 +23,14 @@ const AccessControlModal: React.FC<
const handleOnFinish = async (values: AccessControlFormData) => { const handleOnFinish = async (values: AccessControlFormData) => {
try { try {
const data = { const data: any = {
access_policy: values.access_policy, access_policy: values.access_policy,
// `users` is only meaningful for the legacy `allowed_users`
// policy; for the plugin override (typically the principal-
// based policy) the plugin's Field manages its own state
// inline via the principal CRUD endpoints, so we send an
// empty list to clear any stale user grants from a prior
// policy switch.
users: users:
values.access_policy === 'allowed_users' ? values.users || [] : [] values.access_policy === 'allowed_users' ? values.users || [] : []
}; };