mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-29 19:41:36 +08:00
fix: enforce permission-aware navigation
This commit is contained in:
@@ -4,7 +4,7 @@ import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -13,10 +13,7 @@ const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
// 应用分组(以header分组)
|
||||
const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
|
||||
@@ -5,13 +5,14 @@ import 'gridstack/dist/gridstack.min.css'
|
||||
import api from '@/api'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { DashboardItem } from '@/api/types'
|
||||
import { useUserStore } from '@/stores'
|
||||
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||
@@ -22,13 +23,14 @@ const { t } = useI18n()
|
||||
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const userStore = useUserStore()
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
)
|
||||
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
|
||||
// 从用户 Store 中获取superuser信息
|
||||
const superUser = useUserStore().superUser
|
||||
|
||||
const DASHBOARD_GRID_COLUMNS = 12
|
||||
const DASHBOARD_GRID_CELL_HEIGHT = 16
|
||||
const DASHBOARD_GRID_FALLBACK_ROWS = 4
|
||||
@@ -461,6 +463,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
||||
title: isLayoutEditing.value ? t('dashboard.exitEditMode') : t('dashboard.editLayout'),
|
||||
icon: isLayoutEditing.value ? 'mdi-check' : 'mdi-view-dashboard-edit',
|
||||
color: 'primary',
|
||||
permission: 'admin',
|
||||
action: toggleDashboardLayoutEditing,
|
||||
},
|
||||
]
|
||||
@@ -470,6 +473,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
||||
title: t('dashboard.resetLayout'),
|
||||
icon: 'mdi-restore',
|
||||
color: 'warning',
|
||||
permission: 'admin',
|
||||
action: resetDashboardGridLayout,
|
||||
})
|
||||
}
|
||||
@@ -478,6 +482,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
||||
title: t('dashboard.settings'),
|
||||
icon: 'mdi-tune',
|
||||
color: 'info',
|
||||
permission: 'admin',
|
||||
action: openDashboardSettings,
|
||||
})
|
||||
|
||||
@@ -487,6 +492,7 @@ const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undef
|
||||
useDynamicButton({
|
||||
icon: 'mdi-view-dashboard-edit',
|
||||
menuItems: dashboardDynamicButtonMenuItems,
|
||||
permission: 'admin',
|
||||
show: computed(() => appMode.value && route.path === '/dashboard'),
|
||||
})
|
||||
|
||||
@@ -583,9 +589,6 @@ function buildPluginDashboardId(plugin_id: string, key: string) {
|
||||
|
||||
// 调用API获取所有插件的仪表板元信息
|
||||
async function getPluginDashboardMeta() {
|
||||
// 只有超级用户才能获取
|
||||
if (!superUser) return
|
||||
|
||||
try {
|
||||
pluginDashboardMeta.value = (await api.get('/plugin/dashboard/meta')) ?? []
|
||||
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
|
||||
@@ -628,7 +631,7 @@ function schedulePluginDashboardRefresh(item: DashboardItem) {
|
||||
}
|
||||
|
||||
function refreshEnabledPluginDashboards() {
|
||||
if (!superUser || isNullOrEmptyObject(pluginDashboardMeta.value)) return
|
||||
if (isNullOrEmptyObject(pluginDashboardMeta.value)) return
|
||||
|
||||
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
|
||||
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
||||
@@ -1048,7 +1051,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && route.path === '/dashboard'">
|
||||
<div class="compact-fab-stack">
|
||||
<div v-if="canAdmin" class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-tune"
|
||||
color="info"
|
||||
|
||||
@@ -179,6 +179,7 @@ registerHeaderTab({
|
||||
variant: 'text',
|
||||
color: 'grey',
|
||||
class: 'settings-icon-button',
|
||||
permission: 'discovery',
|
||||
action: openOrderConfigDialog,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
|
||||
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
|
||||
@@ -539,10 +539,7 @@ async function handleLoginSuccess(response: any) {
|
||||
wizard: response.wizard,
|
||||
}
|
||||
|
||||
const userPermissions = {
|
||||
is_superuser: userPayload.superUser,
|
||||
...userPayload.permissions,
|
||||
}
|
||||
const userPermissions = buildUserPermissionContext(userPayload.superUser, userPayload.permissions)
|
||||
|
||||
const filteredMenus = filterMenusByPermission(navMenus.value, userPermissions)
|
||||
if (filteredMenus.length === 0) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { usePWA } from '@/composables/usePWA'
|
||||
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { getRecommendTabs } from '@/router/i18n-menu'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
|
||||
|
||||
@@ -16,9 +18,13 @@ const { appMode } = usePWA()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
const canDiscovery = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'discovery'),
|
||||
)
|
||||
|
||||
// 当前选择的分类
|
||||
const currentCategory = ref(t('recommend.all'))
|
||||
@@ -235,6 +241,7 @@ registerHeaderTab({
|
||||
useDynamicButton({
|
||||
icon: 'mdi-tune',
|
||||
onClick: openRecommendSettings,
|
||||
permission: 'discovery',
|
||||
show: computed(() => appMode.value),
|
||||
})
|
||||
|
||||
@@ -298,7 +305,7 @@ onActivated(async () => {
|
||||
|
||||
<!-- 快速滚动到顶部按钮 -->
|
||||
<Teleport to="body" v-if="route.path === '/recommend'">
|
||||
<div v-if="!appMode" class="compact-fab-stack">
|
||||
<div v-if="!appMode && canDiscovery" class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-tune"
|
||||
color="primary"
|
||||
|
||||
@@ -17,11 +17,17 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const { appMode } = usePWA()
|
||||
const userStore = useUserStore()
|
||||
const canSearch = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'search'),
|
||||
)
|
||||
|
||||
// 提示框
|
||||
const toast = useToast()
|
||||
@@ -257,6 +263,7 @@ function toggleViewType() {
|
||||
useDynamicButton({
|
||||
icon: viewToggleIcon,
|
||||
onClick: toggleViewType,
|
||||
permission: 'search',
|
||||
show: computed(() => appMode.value && isRefreshed.value),
|
||||
})
|
||||
|
||||
@@ -1579,7 +1586,7 @@ onUnmounted(() => {
|
||||
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
|
||||
|
||||
<Teleport to="body" v-if="route.path === '/resource'">
|
||||
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
|
||||
<div v-if="isRefreshed && !appMode && canSearch" class="compact-fab-stack">
|
||||
<VFab
|
||||
:icon="viewToggleIcon"
|
||||
color="primary"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
|
||||
|
||||
@@ -193,9 +194,12 @@ function selectSubscribeSort(value: SubscribeSortBy) {
|
||||
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
|
||||
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
|
||||
|
||||
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub')
|
||||
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && userStore.superUser)
|
||||
const showShareStatisticsAction = computed(() => activeTab.value === 'share')
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub' && canAdmin.value)
|
||||
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && canAdmin.value)
|
||||
const showShareStatisticsAction = computed(() => activeTab.value === 'share' && canSubscribe.value)
|
||||
|
||||
function openDefaultRuleDialog() {
|
||||
openSharedDialog(
|
||||
@@ -255,6 +259,7 @@ const subscribeDynamicMenuItems = computed(() => {
|
||||
titleKey: string
|
||||
titleParams?: Record<string, unknown>
|
||||
icon: string
|
||||
permission: 'admin'
|
||||
action: () => void
|
||||
}> = []
|
||||
|
||||
@@ -263,6 +268,7 @@ const subscribeDynamicMenuItems = computed(() => {
|
||||
titleKey: 'dialog.subscribeHistory.title',
|
||||
titleParams: { type: subType },
|
||||
icon: 'mdi-history',
|
||||
permission: 'admin',
|
||||
action: openSubscribeHistoryDialog,
|
||||
})
|
||||
}
|
||||
@@ -270,6 +276,7 @@ const subscribeDynamicMenuItems = computed(() => {
|
||||
items.push({
|
||||
titleKey: 'dialog.subscribeEdit.titleDefault',
|
||||
icon: 'mdi-clipboard-edit-outline',
|
||||
permission: 'admin',
|
||||
action: openDefaultRuleDialog,
|
||||
})
|
||||
|
||||
@@ -305,6 +312,7 @@ useDynamicButton({
|
||||
icon: subscribeDynamicIcon,
|
||||
onClick: handleSubscribeDynamicAction,
|
||||
menuItems: subscribeDynamicMenuItems,
|
||||
permission: 'subscribe',
|
||||
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
|
||||
})
|
||||
|
||||
@@ -322,6 +330,7 @@ registerHeaderTab({
|
||||
color: filterButtonColor,
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'filter-btn',
|
||||
permission: 'subscribe',
|
||||
action: () => {
|
||||
filterSubscribeDialog.value = true
|
||||
},
|
||||
@@ -332,6 +341,7 @@ registerHeaderTab({
|
||||
variant: 'text',
|
||||
color: computed(() => (subscribeSortMode.value ? 'warning' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
permission: 'subscribe',
|
||||
action: toggleSubscribeSortMode,
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
@@ -340,6 +350,7 @@ registerHeaderTab({
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
permission: 'subscribe',
|
||||
action: () => {
|
||||
// 触发批量管理模式
|
||||
const event = new CustomEvent('toggle-batch-mode')
|
||||
@@ -353,6 +364,7 @@ registerHeaderTab({
|
||||
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'share-filter-btn',
|
||||
permission: 'subscribe',
|
||||
action: () => {
|
||||
searchShareDialog.value = true
|
||||
},
|
||||
|
||||
@@ -7,12 +7,18 @@ import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { getWorkflowTabs } from '@/router/i18n-menu'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
const userStore = useUserStore()
|
||||
const canManage = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
||||
)
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'list')
|
||||
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
|
||||
@@ -61,6 +67,7 @@ onUnmounted(() => {
|
||||
useDynamicButton({
|
||||
icon: 'mdi-plus',
|
||||
onClick: openAddWorkflowDialog,
|
||||
permission: 'manage',
|
||||
show: computed(() => appMode.value && activeTab.value === 'list'),
|
||||
})
|
||||
|
||||
@@ -78,6 +85,7 @@ registerHeaderTab({
|
||||
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'share-filter-btn',
|
||||
permission: 'manage',
|
||||
show: computed(() => activeTab.value === 'share'),
|
||||
action: () => {
|
||||
searchShareDialog.value = true
|
||||
@@ -138,7 +146,7 @@ onMounted(() => {
|
||||
</VMenu>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && route.path === '/workflow' && activeTab === 'list'">
|
||||
<Teleport to="body" v-if="!appMode && route.path === '/workflow' && activeTab === 'list' && canManage">
|
||||
<div class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-plus"
|
||||
|
||||
Reference in New Issue
Block a user