mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36:37 +08:00
Hide scrape and library admin controls from non-admin users.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,6 +37,7 @@ export function GlobalEvents() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (topic === 'scrape' && p.finished) {
|
if (topic === 'scrape' && p.finished) {
|
||||||
|
if (role !== 'admin') return
|
||||||
const processed = Number(p.processed ?? 0)
|
const processed = Number(p.processed ?? 0)
|
||||||
const matched = Number(p.matched ?? 0)
|
const matched = Number(p.matched ?? 0)
|
||||||
const failed = Number(p.failed ?? 0)
|
const failed = Number(p.failed ?? 0)
|
||||||
@@ -51,6 +52,7 @@ export function GlobalEvents() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (topic === 'subscription') {
|
if (topic === 'subscription') {
|
||||||
|
if (role !== 'admin') return
|
||||||
const queued = (p.queued as number | undefined) ?? 0
|
const queued = (p.queued as number | undefined) ?? 0
|
||||||
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
|
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { User } from '../types'
|
|||||||
|
|
||||||
export function useLayoutPermissions(user: User | null | undefined) {
|
export function useLayoutPermissions(user: User | null | undefined) {
|
||||||
const permissions = usePermissionStore((state) => state.permissions)
|
const permissions = usePermissionStore((state) => state.permissions)
|
||||||
const isSuper = usePermissionStore((state) => state.isSuper)
|
|
||||||
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
|
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
|
||||||
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
|
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
|
||||||
|
|
||||||
@@ -17,8 +16,8 @@ export function useLayoutPermissions(user: User | null | undefined) {
|
|||||||
|
|
||||||
const isAdmin = user?.role === 'admin'
|
const isAdmin = user?.role === 'admin'
|
||||||
const can = useCallback(
|
const can = useCallback(
|
||||||
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
|
(key: string) => isAdmin || (permissions ?? {})[key] === true,
|
||||||
[isAdmin, isSuper, permissions],
|
[isAdmin, permissions],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { can, isAdmin }
|
return { can, isAdmin }
|
||||||
|
|||||||
@@ -30,26 +30,24 @@ export function usePermission(
|
|||||||
const { autoFetch = true } = options
|
const { autoFetch = true } = options
|
||||||
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
|
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
|
||||||
const hasPermission = usePermissionStore((s) => s.hasPermission)
|
const hasPermission = usePermissionStore((s) => s.hasPermission)
|
||||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
|
||||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||||
const tier = useAuthStore((state) => state.tier)
|
|
||||||
const role = useAuthStore((state) => state.user?.role)
|
const role = useAuthStore((state) => state.user?.role)
|
||||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||||
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
|
const isAdmin = role === 'admin'
|
||||||
|
|
||||||
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
||||||
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSuperAccess && isAuthenticated && autoFetch) {
|
if (!isAdmin && isAuthenticated && autoFetch) {
|
||||||
const { permissions, isLoading } = usePermissionStore.getState()
|
const { permissions, isLoading } = usePermissionStore.getState()
|
||||||
if (Object.keys(permissions).length === 0 && !isLoading) {
|
if (Object.keys(permissions).length === 0 && !isLoading) {
|
||||||
fetchPermissions()
|
fetchPermissions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated])
|
}, [autoFetch, fetchPermissions, isAdmin, isAuthenticated])
|
||||||
|
|
||||||
// 超级用户有所有权限
|
// 管理员拥有全部能力;plus / is_super 不能用来展示刮削、整理、删除等管理入口
|
||||||
if (hasSuperAccess) {
|
if (isAdmin) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,15 +85,14 @@ export function usePermission(
|
|||||||
export function usePermissions() {
|
export function usePermissions() {
|
||||||
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
|
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
|
||||||
const permissions = usePermissionStore((s) => s.permissions)
|
const permissions = usePermissionStore((s) => s.permissions)
|
||||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
|
||||||
const isLoading = usePermissionStore((s) => s.isLoading)
|
const isLoading = usePermissionStore((s) => s.isLoading)
|
||||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||||
const tier = useAuthStore((state) => state.tier)
|
|
||||||
const role = useAuthStore((state) => state.user?.role)
|
const role = useAuthStore((state) => state.user?.role)
|
||||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||||
|
const isAdmin = role === 'admin'
|
||||||
|
|
||||||
const check = (key: string): boolean => {
|
const check = (key: string): boolean => {
|
||||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
if (isAdmin) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return permissions[key] === true
|
return permissions[key] === true
|
||||||
@@ -103,7 +100,8 @@ export function usePermissions() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
permissions,
|
permissions,
|
||||||
isSuper: isSuper || tier === 'plus' || role === 'admin',
|
// Keep the field name for callers, but only admins are treated as full-access.
|
||||||
|
isSuper: isAdmin,
|
||||||
isLoading,
|
isLoading,
|
||||||
check,
|
check,
|
||||||
refetch: fetchPermissions,
|
refetch: fetchPermissions,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toolsAPI } from '../api/tools'
|
|||||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import {
|
import {
|
||||||
LibrariesContent,
|
LibrariesContent,
|
||||||
LibrariesEmptyState,
|
LibrariesEmptyState,
|
||||||
@@ -18,6 +19,7 @@ import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
|||||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
|
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||||
@@ -103,7 +105,7 @@ export function LibrariesPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function handleRepairRescrape() {
|
async function handleRepairRescrape() {
|
||||||
if (repairing) return
|
if (!isAdmin || repairing) return
|
||||||
setRepairing(true)
|
setRepairing(true)
|
||||||
setRepairMsg('')
|
setRepairMsg('')
|
||||||
try {
|
try {
|
||||||
@@ -117,6 +119,7 @@ export function LibrariesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleManageLibraries = async () => {
|
const handleManageLibraries = async () => {
|
||||||
|
if (!isAdmin) return
|
||||||
await openManageLibrariesDialog()
|
await openManageLibrariesDialog()
|
||||||
await loadLibraries({ force: true })
|
await loadLibraries({ force: true })
|
||||||
}
|
}
|
||||||
@@ -154,6 +157,7 @@ export function LibrariesPage() {
|
|||||||
<LibrariesHeader
|
<LibrariesHeader
|
||||||
previewCount={previews.length}
|
previewCount={previews.length}
|
||||||
total={total}
|
total={total}
|
||||||
|
isAdmin={isAdmin}
|
||||||
repairMsg={repairMsg}
|
repairMsg={repairMsg}
|
||||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||||
repairing={repairing}
|
repairing={repairing}
|
||||||
@@ -163,7 +167,7 @@ export function LibrariesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{previews.length === 0 ? (
|
{previews.length === 0 ? (
|
||||||
<LibrariesEmptyState />
|
<LibrariesEmptyState isAdmin={isAdmin} />
|
||||||
) : (
|
) : (
|
||||||
<LibrariesContent
|
<LibrariesContent
|
||||||
previews={sortedPreviews}
|
previews={sortedPreviews}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { LibraryPreview } from './librariesPageModel'
|
|||||||
export function LibrariesHeader({
|
export function LibrariesHeader({
|
||||||
previewCount,
|
previewCount,
|
||||||
total,
|
total,
|
||||||
|
isAdmin,
|
||||||
repairMsg,
|
repairMsg,
|
||||||
repairEpisodeArtwork,
|
repairEpisodeArtwork,
|
||||||
repairing,
|
repairing,
|
||||||
@@ -18,6 +19,7 @@ export function LibrariesHeader({
|
|||||||
}: {
|
}: {
|
||||||
previewCount: number
|
previewCount: number
|
||||||
total: number
|
total: number
|
||||||
|
isAdmin: boolean
|
||||||
repairMsg: string
|
repairMsg: string
|
||||||
repairEpisodeArtwork: boolean
|
repairEpisodeArtwork: boolean
|
||||||
repairing: boolean
|
repairing: boolean
|
||||||
@@ -33,49 +35,53 @@ export function LibrariesHeader({
|
|||||||
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
{isAdmin && (
|
||||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
<EpisodeArtworkToggle
|
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||||
checked={repairEpisodeArtwork}
|
<EpisodeArtworkToggle
|
||||||
onChange={onRepairEpisodeArtworkChange}
|
checked={repairEpisodeArtwork}
|
||||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
onChange={onRepairEpisodeArtworkChange}
|
||||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||||
/>
|
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
onClick={onRepairRescrape}
|
type="button"
|
||||||
disabled={repairing}
|
onClick={onRepairRescrape}
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
disabled={repairing}
|
||||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
>
|
||||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||||
</button>
|
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||||
<Link
|
</button>
|
||||||
to="/scraper/queue"
|
<Link
|
||||||
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
to="/scraper/queue"
|
||||||
title="查看正在进行的刮削任务与进度"
|
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
>
|
title="查看正在进行的刮削任务与进度"
|
||||||
<Sparkles size={14} className="text-brand-500" />
|
>
|
||||||
<span>刮削队列</span>
|
<Sparkles size={14} className="text-brand-500" />
|
||||||
</Link>
|
<span>刮削队列</span>
|
||||||
<button
|
</Link>
|
||||||
type="button"
|
<button
|
||||||
onClick={onManageLibraries}
|
type="button"
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
onClick={onManageLibraries}
|
||||||
>
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
管理媒体库
|
>
|
||||||
</button>
|
管理媒体库
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibrariesEmptyState() {
|
export function LibrariesEmptyState({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] py-24 text-center">
|
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] py-24 text-center">
|
||||||
<LibraryIcon className="mb-4 h-12 w-12 text-[var(--app-muted)]" />
|
<LibraryIcon className="mb-4 h-12 w-12 text-[var(--app-muted)]" />
|
||||||
<p className="text-sm text-[var(--app-muted)]">暂无媒体库,请到管理后台添加目录。</p>
|
<p className="text-sm text-[var(--app-muted)]">
|
||||||
|
{isAdmin ? '暂无媒体库,请到管理后台添加目录。' : '暂无可用媒体库。'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,9 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
|
|||||||
|
|
||||||
hasPermission: (key: string) => {
|
hasPermission: (key: string) => {
|
||||||
const state = get()
|
const state = get()
|
||||||
// Super user (admin or plus) has all permissions
|
// Only admins implicitly have every capability in the UI.
|
||||||
if (state.isSuper) {
|
// Plus / is_super must not reveal scrape/organize/delete controls.
|
||||||
|
if (state.role === 'admin') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return (state.permissions ?? {})[key] === true
|
return (state.permissions ?? {})[key] === true
|
||||||
|
|||||||
Reference in New Issue
Block a user