fix(rbac): hide UI mutation surfaces for Viewer / non-creator
Several mutation entry points stayed visible to users who lacked the
backend-required role, so Viewers (and non-creator Contributors) hit
403 on click instead of seeing the disabled state. Surgical fixes,
all matching the existing backend matrix:
- KnowledgeBaseList: gate the entire 3-dot popup (both the 'all'
and 'mine' tabs) behind canManageKBCard. Pin/unpin used to be
unguarded; settings/delete were per-item gated but the trigger
still showed. Now non-managers don't see the popup at all.
- AgentList: the popup trigger now requires at least one menu item
to be visible (edit OR copy OR disable OR delete), via a compound
v-if on the popup itself. Disable/enable on own custom agents
and shared agents both gain a hasRole('admin') guard to match
POST /shared-agents/disabled, which writes a tenant-level
preference and is Admin-gated.
- AgentEditorModal: new readOnly prop hides the '确定' (save)
button and replaces 'Cancel' with 'Close'. AgentList passes
readOnly when the user can't manage the agent (built-in agents
and others' custom agents for Viewers). Form-input disabling is
intentionally NOT wired here yet — the modal has 3000+ lines of
inputs and the only mutation surface that 403s is the save
button. Hiding it removes the broken path without rewiring the
whole form.
- doc-content: generated-question delete button gated by a new
canEditKB prop driven by KnowledgeBase.vue's existing canEdit
computed (KB creator OR Admin OR org-share-edit). Defaults to
Admin-only when the prop is missing — fail-closed since the
backend matrix is now stricter (see follow-up commit).
The server still enforces the same matrix on every mutation; these
gates only stop the user from staring at a 'failed' toast.
This commit is contained in:
@@ -13,9 +13,21 @@ import { MessagePlugin, DialogPlugin } from "tdesign-vue-next";
|
||||
import { sanitizeHTML, safeMarkdownToHTML, createSafeImage, isValidImageURL, hydrateProtectedFileImages, isValidURL } from '@/utils/security';
|
||||
import { openMermaidFullscreen } from '@/utils/mermaidViewer';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import DocumentPreview from '@/components/document-preview.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// canDeleteGeneratedQuestion 对应后端 DELETE /chunks/by-id/:id/questions
|
||||
// 的 OwnedChunkKBOrAdminFromChunkID 守卫——KB 创建者或租户 Admin+
|
||||
// 才允许删除。父组件 KnowledgeBase.vue 通过 :canEditKB 把 KB 级权限
|
||||
// 传下来(包含 KB creator / Admin / 组织分享 editor 三种来源),未
|
||||
// 传时按更严格的 Admin 兜底,避免 Viewer 看到一个会 403 的入口。
|
||||
const canDeleteGeneratedQuestion = computed(() => {
|
||||
if (props.canEditKB === true) return true;
|
||||
return authStore.hasRole('admin');
|
||||
});
|
||||
|
||||
// Mermaid 初始化计数器,用于生成唯一ID
|
||||
let mermaidRenderCount = 0;
|
||||
@@ -48,7 +60,7 @@ mermaid.initialize({
|
||||
topPadding: 50
|
||||
}
|
||||
});
|
||||
const props = defineProps(["visible", "details", "knowledgeType", "sourceInfo"]);
|
||||
const props = defineProps(["visible", "details", "knowledgeType", "sourceInfo", "canEditKB"]);
|
||||
const emit = defineEmits(["closeDoc", "getDoc", "questionDeleted"]);
|
||||
|
||||
marked.use({
|
||||
@@ -955,9 +967,10 @@ const handleDetailsScroll = () => {
|
||||
>
|
||||
<t-icon name="help-circle" size="14px" class="question-icon" />
|
||||
<span class="question-text">{{ question.question }}</span>
|
||||
<t-button
|
||||
theme="default"
|
||||
variant="text"
|
||||
<t-button
|
||||
v-if="canDeleteGeneratedQuestion"
|
||||
theme="default"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="delete-question-btn"
|
||||
:loading="isDeleting(index, question.id)"
|
||||
|
||||
@@ -1398,8 +1398,8 @@
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="settings-footer">
|
||||
<t-button variant="outline" @click="handleClose">{{ $t('common.cancel') }}</t-button>
|
||||
<t-button theme="primary" :loading="saving" @click="handleSave">{{ $t('common.confirm') }}</t-button>
|
||||
<t-button variant="outline" @click="handleClose">{{ props.readOnly ? $t('common.close') : $t('common.cancel') }}</t-button>
|
||||
<t-button v-if="!props.readOnly" theme="primary" :loading="saving" @click="handleSave">{{ $t('common.confirm') }}</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1458,6 +1458,12 @@ const props = defineProps<{
|
||||
mode: 'create' | 'edit';
|
||||
agent?: CustomAgent | null;
|
||||
initialSection?: string;
|
||||
// readOnly hides the save button so a Viewer who clicks an agent
|
||||
// card to inspect its config doesn't see a "确定" that 403s on the
|
||||
// backend update endpoint. Field-level disable is intentionally NOT
|
||||
// wired here yet (the modal has 3000+ lines of form inputs); instead
|
||||
// we just remove the only mutation surface — the footer button.
|
||||
readOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<span class="card-title" :title="agent.name">{{ agent.name }}</span>
|
||||
</div>
|
||||
<t-popup
|
||||
v-if="agent.isMine"
|
||||
v-if="agent.isMine && (canManageAgent(agent) || authStore.hasRole('contributor') || (!agent.is_builtin && authStore.hasRole('admin')))"
|
||||
:visible="openMoreAgentId === agent.id"
|
||||
trigger="hover"
|
||||
overlayClassName="card-more-popup"
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="popup-menu">
|
||||
<div v-if="canManageAgent(agent)" class="popup-menu-item" @click="handleEdit(agent)"><t-icon class="menu-icon" name="edit" /><span>{{ $t('common.edit') }}</span></div>
|
||||
<div v-if="authStore.hasRole('contributor')" class="popup-menu-item" @click="handleCopy(agent)"><t-icon class="menu-icon" name="file-copy" /><span>{{ $t('common.copy') }}</span></div>
|
||||
<div v-if="!agent.is_builtin" class="popup-menu-item" @click="handleToggleDisabled(agent)">
|
||||
<div v-if="!agent.is_builtin && authStore.hasRole('admin')" class="popup-menu-item" @click="handleToggleDisabled(agent)">
|
||||
<t-icon class="menu-icon" name="poweroff" />
|
||||
<span>{{ agent.disabled_by_me ? $t('agent.enable') : $t('agent.disable') }}</span>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
</template>
|
||||
</t-popup>
|
||||
<t-popup
|
||||
v-else
|
||||
v-else-if="!agent.isMine && authStore.hasRole('admin')"
|
||||
:visible="openMoreAgentId === 'shared-' + agent.share_id"
|
||||
trigger="hover"
|
||||
overlayClassName="card-more-popup"
|
||||
@@ -226,6 +226,7 @@
|
||||
<span class="card-title" :title="agent.name">{{ agent.name }}</span>
|
||||
</div>
|
||||
<t-popup
|
||||
v-if="canManageAgent(agent) || authStore.hasRole('contributor') || (!agent.is_builtin && authStore.hasRole('admin'))"
|
||||
:visible="openMoreAgentId === agent.id"
|
||||
trigger="hover"
|
||||
overlayClassName="card-more-popup"
|
||||
@@ -251,7 +252,7 @@
|
||||
<t-icon class="menu-icon" name="file-copy" />
|
||||
<span>{{ $t('common.copy') }}</span>
|
||||
</div>
|
||||
<div v-if="!agent.is_builtin" class="popup-menu-item" @click="handleToggleDisabled(agent)">
|
||||
<div v-if="!agent.is_builtin && authStore.hasRole('admin')" class="popup-menu-item" @click="handleToggleDisabled(agent)">
|
||||
<t-icon class="menu-icon" name="poweroff" />
|
||||
<span>{{ agent.disabled_by_me ? $t('agent.enable') : $t('agent.disable') }}</span>
|
||||
</div>
|
||||
@@ -536,11 +537,12 @@
|
||||
</Transition>
|
||||
|
||||
<!-- 智能体编辑器弹窗 -->
|
||||
<AgentEditorModal
|
||||
<AgentEditorModal
|
||||
:visible="editorVisible"
|
||||
:mode="editorMode"
|
||||
:agent="editingAgent"
|
||||
:initialSection="editorInitialSection"
|
||||
:readOnly="editorMode === 'edit' && editingAgent != null && !canManageAgent(editingAgent as AgentWithUI)"
|
||||
@update:visible="editorVisible = $event"
|
||||
@success="handleEditorSuccess"
|
||||
/>
|
||||
|
||||
@@ -2711,7 +2711,7 @@ async function createNewSession(value: string): Promise<void> {
|
||||
</template>
|
||||
|
||||
<!-- DocContent drawer (shared by documents tab and wiki source refs) -->
|
||||
<DocContent :visible="isCardDetails" :details="details" @closeDoc="closeDoc" @getDoc="getDoc"></DocContent>
|
||||
<DocContent :visible="isCardDetails" :details="details" :canEditKB="canEdit" @closeDoc="closeDoc" @getDoc="getDoc"></DocContent>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
<div class="card-header">
|
||||
<span class="card-title" :title="kb.name">{{ kb.name }}</span>
|
||||
<t-popup
|
||||
v-if="canManageKBCard(kb)"
|
||||
overlayClassName="card-more-popup"
|
||||
trigger="click"
|
||||
destroy-on-close
|
||||
@@ -313,6 +314,7 @@
|
||||
<div class="card-header">
|
||||
<span class="card-title" :title="kb.name">{{ kb.name }}</span>
|
||||
<t-popup
|
||||
v-if="canManageKBCard(kb)"
|
||||
v-model="kb.showMore"
|
||||
overlayClassName="card-more-popup"
|
||||
:on-visible-change="onVisibleChange"
|
||||
@@ -334,11 +336,11 @@
|
||||
<t-icon class="menu-icon" :name="kb.is_pinned ? 'pin-filled' : 'pin'" />
|
||||
<span>{{ kb.is_pinned ? $t('knowledgeList.pin.unpin') : $t('knowledgeList.pin.pin') }}</span>
|
||||
</div>
|
||||
<div v-if="canManageKBCard(kb)" class="popup-menu-item" @click.stop="handleSettings(kb)">
|
||||
<div class="popup-menu-item" @click.stop="handleSettings(kb)">
|
||||
<t-icon class="menu-icon" name="setting" />
|
||||
<span>{{ $t('knowledgeBase.settings') }}</span>
|
||||
</div>
|
||||
<div v-if="canManageKBCard(kb)" class="popup-menu-item delete" @click.stop="handleDelete(kb)">
|
||||
<div class="popup-menu-item delete" @click.stop="handleDelete(kb)">
|
||||
<t-icon class="menu-icon" name="delete" />
|
||||
<span>{{ $t('common.delete') }}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user