fix(wiki): gate write controls behind the manage:wiki capability

This commit is contained in:
matevip 2026-05-17 17:55:48 +08:00
parent c9f73bbbd5
commit 71aba2649e
7 changed files with 61 additions and 17 deletions

View File

@ -45,7 +45,7 @@
<!-- Error state with actions --> <!-- Error state with actions -->
<div v-if="status === 'failed'" class="stage-error"> <div v-if="status === 'failed'" class="stage-error">
<span class="error-badge"> {{ t('wiki.jobStage.failed') }} {{ errorCode }}</span> <span class="error-badge"> {{ t('wiki.jobStage.failed') }} {{ errorCode }}</span>
<div class="error-actions"> <div v-if="canManageWiki" class="error-actions">
<button class="btn-mini" @click="$emit('reprocess')">{{ t('wiki.reprocess') }}</button> <button class="btn-mini" @click="$emit('reprocess')">{{ t('wiki.reprocess') }}</button>
<button class="btn-mini btn-mini-alt" @click="$emit('repair')">{{ t('wiki.page.repair') }}</button> <button class="btn-mini btn-mini-alt" @click="$emit('repair')">{{ t('wiki.page.repair') }}</button>
</div> </div>
@ -56,8 +56,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
const { t } = useI18n() const { t } = useI18n()
const workspace = useWorkspaceStore()
// Reprocess / repair are write actions viewers see job status without them.
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const props = defineProps<{ const props = defineProps<{
stage: string stage: string

View File

@ -24,7 +24,7 @@
<el-icon :size="12"><Link /></el-icon> <el-icon :size="12"><Link /></el-icon>
{{ t('wiki.page.enriched') }} {{ t('wiki.page.enriched') }}
</span> </span>
<button v-else class="btn-mini-enrich" @click="$emit('enrich')"> <button v-else-if="canManageWiki" class="btn-mini-enrich" @click="$emit('enrich')">
<el-icon :size="12"><Link /></el-icon> <el-icon :size="12"><Link /></el-icon>
{{ t('wiki.page.enrich') }} {{ t('wiki.page.enrich') }}
</button> </button>
@ -41,8 +41,13 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Link } from '@element-plus/icons-vue' import { Link } from '@element-plus/icons-vue'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
const { t } = useI18n() const { t } = useI18n()
const workspace = useWorkspaceStore()
// Enriching a page (adding cross-links) is a write action viewers only read.
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const props = defineProps<{ const props = defineProps<{
page: { page: {

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="raw-panel"> <div class="raw-panel">
<!-- Upload + Add text row --> <!-- Upload + Add text row -->
<div class="upload-row"> <div v-if="canManageWiki" class="upload-row">
<div <div
class="upload-zone" class="upload-zone"
:class="{ 'is-dragging': isDragging, 'is-uploading': uploadingFiles.length > 0 }" :class="{ 'is-dragging': isDragging, 'is-uploading': uploadingFiles.length > 0 }"
@ -45,7 +45,7 @@
</div> </div>
<!-- Directory scan --> <!-- Directory scan -->
<div class="dir-scan-row"> <div v-if="canManageWiki" class="dir-scan-row">
<div class="dir-input-wrap"> <div class="dir-input-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
@ -163,7 +163,7 @@
{{ raw.errorMessage }} {{ raw.errorMessage }}
</span> </span>
</div> </div>
<div class="raw-item-actions"> <div v-if="canManageWiki" class="raw-item-actions">
<button <button
v-if="raw.processingStatus === 'processing' && !cancellingIds.has(raw.id)" v-if="raw.processingStatus === 'processing' && !cancellingIds.has(raw.id)"
class="btn-icon btn-icon-danger" :title="t('wiki.cancel')" class="btn-icon btn-icon-danger" :title="t('wiki.cancel')"
@ -290,12 +290,18 @@ import { mcToast } from '@/composables/useMcToast'
import { useFileDrop } from '@/composables/useFileDrop' import { useFileDrop } from '@/composables/useFileDrop'
import { Download } from '@element-plus/icons-vue' import { Download } from '@element-plus/icons-vue'
import { useWikiStore } from '@/stores/useWikiStore' import { useWikiStore } from '@/stores/useWikiStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { wikiApi } from '@/api/index' import { wikiApi } from '@/api/index'
import JobStageBar from './JobStageBar.vue' import JobStageBar from './JobStageBar.vue'
import type { WikiProcessingJob } from '@/composables/useWikiJobPoller' import type { WikiProcessingJob } from '@/composables/useWikiJobPoller'
const { t } = useI18n() const { t } = useI18n()
const store = useWikiStore() const store = useWikiStore()
const workspace = useWorkspaceStore()
// Uploading, scanning and (re)processing raw material all require manage:wiki.
// Viewers can still see the material list but get no write controls.
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
// While raw materials are active, subscribe to the backend SSE progress stream. // While raw materials are active, subscribe to the backend SSE progress stream.

View File

@ -9,6 +9,7 @@
@keydown.space.prevent="$emit('open', kb.id)" @keydown.space.prevent="$emit('open', kb.id)"
> >
<button <button
v-if="canManageWiki"
type="button" type="button"
class="kb-card-delete" class="kb-card-delete"
:title="t('wiki.library.deleteKB')" :title="t('wiki.library.deleteKB')"
@ -61,6 +62,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { WikiKB } from '@/stores/useWikiStore' import type { WikiKB } from '@/stores/useWikiStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { kbAccent, kbAccentFg, kbInitial, relativeTime } from '../utils/kbVisual' import { kbAccent, kbAccentFg, kbInitial, relativeTime } from '../utils/kbVisual'
const props = defineProps<{ const props = defineProps<{
@ -74,6 +76,10 @@ defineEmits<{
}>() }>()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const workspace = useWorkspaceStore()
// Deleting a knowledge base requires manage:wiki; hide it from read-only viewers.
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const failedJobCount = computed(() => props.failedJobCount ?? 0) const failedJobCount = computed(() => props.failedJobCount ?? 0)
const initial = computed(() => kbInitial(props.kb)) const initial = computed(() => kbInitial(props.kb))

View File

@ -6,7 +6,7 @@
<h1 class="mc-page-title">{{ t('nav.wiki') }}</h1> <h1 class="mc-page-title">{{ t('nav.wiki') }}</h1>
<p class="mc-page-desc">{{ t('wiki.desc') }}</p> <p class="mc-page-desc">{{ t('wiki.desc') }}</p>
</div> </div>
<button class="btn-primary page-cta" @click="$emit('create')"> <button v-if="canManageWiki" class="btn-primary page-cta" @click="$emit('create')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg> </svg>
@ -48,7 +48,7 @@
</div> </div>
<h3 class="library-empty-title">{{ t('wiki.library.empty') }}</h3> <h3 class="library-empty-title">{{ t('wiki.library.empty') }}</h3>
<p class="library-empty-hint">{{ t('wiki.library.emptyHint') }}</p> <p class="library-empty-hint">{{ t('wiki.library.emptyHint') }}</p>
<button class="btn-primary" @click="$emit('create')"> <button v-if="canManageWiki" class="btn-primary" @click="$emit('create')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t('wiki.createKB') }} {{ t('wiki.createKB') }}
</button> </button>
@ -87,6 +87,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { WikiKB } from '@/stores/useWikiStore' import type { WikiKB } from '@/stores/useWikiStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import McPagination from '@/components/common/McPagination.vue' import McPagination from '@/components/common/McPagination.vue'
import WikiKBCard from './WikiKBCard.vue' import WikiKBCard from './WikiKBCard.vue'
@ -108,6 +109,10 @@ defineEmits<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const workspace = useWorkspaceStore()
// view:wiki users without manage:wiki may browse but not create knowledge bases.
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const searchInput = ref('') const searchInput = ref('')
type SortMode = 'recent' | 'name' | 'pages' type SortMode = 'recent' | 'name' | 'pages'

View File

@ -13,8 +13,8 @@
<p class="summary-text">{{ store.currentPage.summary }}</p> <p class="summary-text">{{ store.currentPage.summary }}</p>
</div> </div>
<!-- Actions bar --> <!-- Actions bar write actions, hidden for read-only viewers -->
<div class="page-actions-bar"> <div v-if="canManageWiki" class="page-actions-bar">
<button class="btn-secondary btn-sm" @click="editing = !editing"> <button class="btn-secondary btn-sm" @click="editing = !editing">
{{ editing ? t('common.cancel') : t('common.edit') }} {{ editing ? t('common.cancel') : t('common.edit') }}
</button> </button>
@ -99,6 +99,7 @@
import { ref, computed, watch, onMounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore' import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { wikiApi } from '@/api/index' import { wikiApi } from '@/api/index'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer' import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { Link, SetUp } from '@element-plus/icons-vue' import { Link, SetUp } from '@element-plus/icons-vue'
@ -109,8 +110,13 @@ import ImageLightbox from './ImageLightbox.vue'
const { t } = useI18n() const { t } = useI18n()
const store = useWikiStore() const store = useWikiStore()
const workspace = useWorkspaceStore()
const { renderMarkdown } = useMarkdownRenderer() const { renderMarkdown } = useMarkdownRenderer()
// Editing, enriching, repairing and deleting pages all require manage:wiki.
// Read-only viewers get the rendered page without the action bar.
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const editing = ref(false) const editing = ref(false)
const editContent = ref('') const editContent = ref('')
const backlinks = ref<WikiPage[]>([]) const backlinks = ref<WikiPage[]>([])

View File

@ -56,6 +56,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useWikiStore, type WikiKB } from '@/stores/useWikiStore' import { useWikiStore, type WikiKB } from '@/stores/useWikiStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import RawMaterialPanel from './RawMaterialPanel.vue' import RawMaterialPanel from './RawMaterialPanel.vue'
import WikiPageViewer from './WikiPageViewer.vue' import WikiPageViewer from './WikiPageViewer.vue'
import WikiConfig from './WikiConfig.vue' import WikiConfig from './WikiConfig.vue'
@ -69,17 +70,27 @@ defineProps<{ kb: WikiKB }>()
const { t } = useI18n() const { t } = useI18n()
const store = useWikiStore() const store = useWikiStore()
const workspace = useWorkspaceStore()
// Read-only viewers (view:wiki without manage:wiki) only get the browsing tabs;
// the processing-config and transformations tabs are management surfaces.
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const activeTab = ref('raw') const activeTab = ref('raw')
const tabs = computed(() => [ const tabs = computed(() => {
const list = [
{ key: 'raw', label: t('wiki.rawMaterials') }, { key: 'raw', label: t('wiki.rawMaterials') },
{ key: 'pages', label: t('wiki.pages') }, { key: 'pages', label: t('wiki.pages') },
{ key: 'graph', label: t('wiki.graph.tab') }, { key: 'graph', label: t('wiki.graph.tab') },
{ key: 'config', label: t('wiki.config') }, ]
{ key: 'transformations', label: t('wiki.transformations.tab') }, if (canManageWiki.value) {
{ key: 'hotCache', label: t('wiki.hotCache.tab') }, list.push({ key: 'config', label: t('wiki.config') })
]) list.push({ key: 'transformations', label: t('wiki.transformations.tab') })
}
list.push({ key: 'hotCache', label: t('wiki.hotCache.tab') })
return list
})
// When the user picks a different KB from the library, snap back to the // When the user picks a different KB from the library, snap back to the
// raw-materials tab so they don't land on stale state from the previous KB. // raw-materials tab so they don't land on stale state from the previous KB.