Hide scrape and library admin controls from non-admin users.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
truewhile
2026-09-15 15:19:54 +08:00
parent c911a57467
commit c74f6be250
6 changed files with 75 additions and 65 deletions
+2
View File
@@ -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} 项下载`)
} }
+2 -3
View File
@@ -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 }
+9 -11
View File
@@ -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,
+6 -2
View File
@@ -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}
+42 -36
View File
@@ -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>
) )
} }
+3 -2
View File
@@ -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