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:
wizardchen
2026-05-15 12:15:26 +08:00
committed by lyingbug
parent 1e8b5c8d40
commit f7a264ca93
5 changed files with 37 additions and 14 deletions
+17 -4
View File
@@ -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<{
+7 -5
View File
@@ -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>