fix(web): clarify knowledge settings permission errors

Keep rejected Score Threshold drafts intact, show the localized permission message for 403 responses, and avoid duplicate error toasts.\n\nLinear: https://linear.app/dify/issue/WTA-2201/修复-score-threshold-保存时的权限错误提示
This commit is contained in:
Stephen Zhou 2026-08-25 15:14:51 +08:00
parent fbc7c85af1
commit beb599c163
No known key found for this signature in database
2 changed files with 79 additions and 15 deletions

View File

@ -949,6 +949,59 @@ describe('KnowledgeSettingsForm', () => {
expect(thresholdSwitch).toHaveAttribute('aria-checked', 'true')
})
it('keeps a rejected score threshold draft and explains permission failures', async () => {
const user = userEvent.setup()
serviceMock.patchSettings.mockRejectedValueOnce(
new Response(
JSON.stringify({
code: 'knowledge_fs_access_denied',
message: 'You do not have permission to perform this KnowledgeFS operation.',
status: 403,
}),
{ status: 403 },
),
)
renderForm({
settings: {
...settings,
retrieval: {
...settings.retrieval,
score_threshold: {
...settings.retrieval.score_threshold,
enabled: true,
},
},
},
})
const thresholdInput = screen.getByRole('spinbutton', {
name: 'appDebug.datasetConfig.score_threshold',
})
await user.clear(thresholdInput)
await user.type(thresholdInput, '0.72')
await user.tab()
expect(await screen.findByText('dataset.newKnowledge.permissionRestricted')).toBeInTheDocument()
expect(screen.queryByText('dataset.newKnowledge.settings.saveFailed')).not.toBeInTheDocument()
expect(thresholdInput).toHaveValue(0.72)
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
await waitFor(() => expect(serviceMock.patchSettings).toHaveBeenCalledTimes(2))
expect(serviceMock.patchSettings).toHaveBeenLastCalledWith(
{
body: {
expectedRevision: 5,
retrieval: expect.objectContaining({
scoreThreshold: expect.objectContaining({ value: 0.72 }),
}),
},
params: { control_space_id: 'space-1' },
},
expect.anything(),
)
})
it('requires a rerank model for a legacy knowledge base and saves it as enabled', async () => {
const user = userEvent.setup()
const onDraftFinish = vi.fn()

View File

@ -64,6 +64,10 @@ const RERANK_MODEL_ERROR_ID = 'knowledge-rerank-model-error'
type BasicSaveSlice = 'members' | 'space'
type SaveErrorSlice = 'basic' | 'externalAccess' | 'settings'
type SaveError = {
kind: 'generic' | 'permission'
slice: SaveErrorSlice
}
type ExternalAccessDraft = {
apiEnabled: boolean
@ -286,7 +290,7 @@ export function KnowledgeSettingsForm({
}),
)
const [nameTouched, setNameTouched] = useState(false)
const [saveErrorSlice, setSaveErrorSlice] = useState<SaveErrorSlice>()
const [saveError, setSaveError] = useState<SaveError>()
const [isBasicRefreshing, setIsBasicRefreshing] = useState(false)
const [pendingMigrationId, setPendingMigrationId] = useState<string>()
const [pendingEmbeddingModel, setPendingEmbeddingModel] = useState<DefaultModel>()
@ -408,7 +412,9 @@ export function KnowledgeSettingsForm({
consoleQuery.knowledgeFs.spaces.byControlSpaceId.externalAccess.put.mutationOptions(),
)
const settingsMutation = useMutation(
consoleQuery.knowledgeFs.spaces.byControlSpaceId.settings.patch.mutationOptions(),
consoleQuery.knowledgeFs.spaces.byControlSpaceId.settings.patch.mutationOptions({
context: { silent: true },
}),
)
const migrationQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.settings.migrations.byMigrationId.get.queryOptions(
@ -463,7 +469,7 @@ export function KnowledgeSettingsForm({
setVisibility(space.visibility)
setSelectedMemberIds(initialSelectedMemberIds)
setNameTouched(false)
if (saveErrorSlice === 'basic') setSaveErrorSlice(undefined)
if (saveError?.slice === 'basic') setSaveError(undefined)
onDraftFinish?.()
}
@ -537,7 +543,7 @@ export function KnowledgeSettingsForm({
// oxlint-disable-next-line eslint-react/set-state-in-effect -- A terminal remote migration retires the local polling guard.
setPendingMigrationId(undefined)
// oxlint-disable-next-line eslint-react/set-state-in-effect -- The authoritative failed migration transitions the save UI to its retry state.
setSaveErrorSlice('settings')
setSaveError({ kind: 'generic', slice: 'settings' })
}
}, [
invalidateSettingsQueries,
@ -550,7 +556,7 @@ export function KnowledgeSettingsForm({
const performBasicSave = async () => {
if (saveDisabled || !canEdit) return
setSaveErrorSlice(undefined)
setSaveError(undefined)
try {
const saveSlice = async (
@ -605,7 +611,7 @@ export function KnowledgeSettingsForm({
() => setIsBasicRefreshing(false),
)
} catch {
setSaveErrorSlice('basic')
setSaveError({ kind: 'generic', slice: 'basic' })
}
}
@ -621,7 +627,7 @@ export function KnowledgeSettingsForm({
let nextDraft: ExternalAccessDraft | undefined = draft
while (nextDraft) {
queuedExternalAccessDraftRef.current = undefined
setSaveErrorSlice(undefined)
setSaveError(undefined)
try {
await externalAccessMutation.mutateAsync({
body: {
@ -639,7 +645,7 @@ export function KnowledgeSettingsForm({
externalAccessSaveInFlightRef.current = false
setApiEnabled(externalAccessBaselineRef.current.apiEnabled)
setWorkflowEnabled(externalAccessBaselineRef.current.workflowEnabled)
setSaveErrorSlice('externalAccess')
setSaveError({ kind: 'generic', slice: 'externalAccess' })
return
}
}
@ -691,7 +697,7 @@ export function KnowledgeSettingsForm({
}
}
setSaveErrorSlice(undefined)
setSaveError(undefined)
try {
const result = await settingsMutation.mutateAsync({
body,
@ -714,8 +720,11 @@ export function KnowledgeSettingsForm({
}
await invalidateSettingsQueries()
return 'saved' as const
} catch {
setSaveErrorSlice('settings')
} catch (error) {
setSaveError({
kind: error instanceof Response && error.status === 403 ? 'permission' : 'generic',
slice: 'settings',
})
return 'failed' as const
}
}
@ -799,11 +808,11 @@ export function KnowledgeSettingsForm({
}
const retrySave = () => {
if (saveErrorSlice === 'externalAccess') {
if (saveError?.slice === 'externalAccess') {
void performExternalAccessSave(pendingExternalAccessRef.current)
return
}
if (saveErrorSlice === 'settings') {
if (saveError?.slice === 'settings') {
void performSettingsSave(pendingSettingsDraftRef.current ?? liveSettingsDraftRef.current)
return
}
@ -976,14 +985,16 @@ export function KnowledgeSettingsForm({
/>
)}
{saveErrorSlice && (
{saveError && (
<div
className="mb-3 flex items-center gap-2 rounded-lg border border-text-destructive/20 bg-background-default-subtle px-3 py-2"
role="alert"
>
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
<span className="min-w-0 flex-1 system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.settings.saveFailed'])}
{saveError.kind === 'permission'
? t(($) => $['newKnowledge.permissionRestricted'])
: t(($) => $['newKnowledge.settings.saveFailed'])}
</span>
<Button type="button" size="small" variant="ghost" onClick={retrySave}>
{tCommon(($) => $['operation.retry'])}