Files
gpustack-ui/src/pages/llmodels/components/access-control-modal/index.tsx
T
gitlawrandjialin a80b889760 refactor(access): fold allowed_users into allowed_principals
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`.
2026-05-29 17:11:12 +08:00

155 lines
5.1 KiB
TypeScript

import { PageActionType } from '@/config/types';
import { RouteItem } from '@/pages/model-routes/config/types';
import { getGPUStackPlugin } from '@/plugins';
import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { message } from 'antd';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { updateModelAccessUser } from '../../apis';
import { AccessControlFormData } from '../../config/types';
import AccessControlForm, { ALLOWED_PRINCIPALS_POLICY } from './form';
const AccessControlModal: React.FC<
Global.ScrollerModalProps<RouteItem, AccessControlFormData>
> = ({ open, title, currentData, action, onCancel }) => {
const intl = useIntl();
const form = useRef<any>(null);
const [isChanged, setIsChanged] = useState(false);
const formCacheRef = useRef<AccessControlFormData | null>(null);
const handleSumit = () => {
form.current?.submit();
};
const handleOnFinish = async (values: AccessControlFormData) => {
try {
// Everything saves through the single `/access` POST as
// `principals` (one request, one success toast):
// * principal-based override active → its staged full grant set.
// * user picker → selected users mapped to USER-kind grants,
// plus any non-user grants preserved from the GET snapshot
// (so a user-only UI never wipes org/group grants it can't
// see — though in practice the picker only runs where none
// exist).
// * otherwise (authed/public) → send neither, so the server
// leaves existing grants untouched.
const override = getGPUStackPlugin()?.accessControl?.allowedUsersOverride;
const usesPrincipals =
!!override && values.access_policy === override.policyValue;
const usesUserList =
!override && values.access_policy === ALLOWED_PRINCIPALS_POLICY;
// Guard the load race: on an existing route, `principals` is
// undefined until the GET seeds it. Saving in that window would
// submit an empty grant set and wipe existing grants — bail out
// (the modal stays open; the user can retry once loaded).
if (
currentData?.id &&
(usesPrincipals || usesUserList) &&
!values.principals
) {
return;
}
let data: AccessControlFormData;
if (usesPrincipals) {
data = {
access_policy: values.access_policy,
principals: values.principals || []
};
} else if (usesUserList) {
const preserved = (values.principals || []).filter(
(p) => p.principal_type !== 'user'
);
const userGrants = (values.users || []).map((u) => ({
principal_type: 'user',
principal_id: u.id
}));
data = {
access_policy: values.access_policy,
principals: [...preserved, ...userGrants]
};
} else {
data = { access_policy: values.access_policy };
}
await updateModelAccessUser({
id: currentData?.id as number,
data: data
});
message.success(intl.formatMessage({ id: 'common.message.success' }));
onCancel?.();
} catch (error) {
// do nothing, error message handled in request layer
}
};
const customEqual = (objValue: any, othValue: any) => {
if (_.isArray(objValue) && _.isArray(othValue)) {
const objIds = objValue.map((item: any) => item.id).sort();
const othIds = othValue.map((item: any) => item.id).sort();
return _.isEqual(objIds, othIds);
}
return undefined;
};
const handleOnValuesChange = async (changedValues: any, allValues: any) => {
const initialValues = formCacheRef.current;
if (_.isEqualWith(initialValues, allValues, customEqual)) {
setIsChanged(false);
} else {
setIsChanged(true);
}
};
useEffect(() => {
if (open) {
setIsChanged(false);
setTimeout(() => {
const initialValues = form.current?.getFieldsValue();
formCacheRef.current = {
access_policy: initialValues.access_policy,
users: initialValues.users || []
};
}, 200);
} else {
formCacheRef.current = null;
setIsChanged(false);
}
}, [open, currentData]);
return (
<FormDrawer
title={title}
open={open}
onCancel={onCancel}
width={700}
footer={
<div style={{ paddingInline: 24, paddingBottom: 8 }}>
{isChanged && (
<AlertBlockInfo
type="warning"
style={{ marginBottom: 16 }}
contentStyle={{ paddingInline: 0 }}
message={intl.formatMessage({
id: 'models.button.accessSettings.tips'
})}
></AlertBlockInfo>
)}
<ModalFooter onOk={handleSumit} onCancel={onCancel}></ModalFooter>
</div>
}
>
<AccessControlForm
ref={form}
currentData={currentData}
action={action as PageActionType}
onFinish={handleOnFinish}
onValuesChange={handleOnValuesChange}
/>
</FormDrawer>
);
};
export default AccessControlModal;