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([]); 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>(new Set()); const [queryParams, setQueryParams] = useState({ page: -1 }); const formDataCacheRef = useRef(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) => { 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(); 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 ( handleCheck(e, 'admin')} > {intl.formatMessage({ id: 'models.table.admin' })} ), key: '0' }, { label: ( handleCheck(e, 'inactive')} > {intl.formatMessage({ id: 'users.status.inactiveAccount' })} ), key: '1' } ] }} > {intl.formatMessage({ id: 'models.accessControlModal.includeusers' })} ); }; return (
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 }} > name="access_policy" noStyle> ({ 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' } ]} > {accessPolicy === 'public' && (
)} {activePrependedPolicy?.Field && ( )} {allowedUsersOverride && accessPolicy === overridePolicyValue && ( )} {!allowedUsersOverride && accessPolicy === ALLOWED_PRINCIPALS_POLICY && ( <> name="users"> , ] }} showSearch={{ placeholder: intl.formatMessage({ id: 'common.filter.name' }) }} filterOption={(inputValue, item) => item.title.toLowerCase().includes(inputValue.toLowerCase()) } render={(item) => ( {item.title} {!item.is_active ? `[${intl.formatMessage({ id: 'users.status.inactive' })}]` : item.is_admin ? `[${intl.formatMessage({ id: 'models.table.admin' })}]` : ''} )} onSearch={onSearch} onChange={handleOnChange} /> )} ); }); export default AccessControlForm;