expand user permissions

This commit is contained in:
Garret Patti
2026-04-18 11:48:01 -04:00
parent 8f84da7e2f
commit 2ea02b197b
27 changed files with 214 additions and 110 deletions

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'describe', libraryId)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const payload: Record<string, string> = {}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
export async function GET(request: NextRequest) {
@@ -35,7 +35,7 @@ export async function PATCH(request: NextRequest) {
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
if (extractedText !== undefined) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'tag', libraryId)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
import { getDb } from '@/lib/db'
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const db = getDb()

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)

View File

@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
try {
const libraries =
session.role === 'admin'
? getLibraries()
? getLibraries().map((l) => ({ ...l, accessLevel: 'admin' }))
: getLibrariesForUser(session.userId, session.role)
return NextResponse.json(libraries)
} catch (err) {

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
import { requireLibraryAccess } from '@/lib/auth'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
function extractLibraryId(itemKey: string): string | null {
const colonIdx = itemKey.indexOf(':')
@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
addTagToItem(itemKey, tagId)
@@ -60,7 +60,7 @@ export async function DELETE(request: NextRequest) {
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
removeTagFromItem(itemKey, tagId)

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getUserById, getPermittedLibraryIds, setLibraryPermissions } from '@/lib/users'
import { getUserById, getLibraryPermissions, setLibraryPermissions, type LibraryPermission } from '@/lib/users'
import { getLibraries } from '@/lib/libraries'
export async function GET(
@@ -17,8 +17,8 @@ export async function GET(
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const libraryIds = getPermittedLibraryIds(id)
return NextResponse.json({ libraryIds })
const permissions = getLibraryPermissions(id)
return NextResponse.json({ permissions })
}
export async function PUT(
@@ -35,24 +35,41 @@ export async function PUT(
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
let body: { libraryIds?: unknown }
let body: { permissions?: unknown }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
if (!Array.isArray(body.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) {
return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 })
if (!Array.isArray(body.permissions)) {
return NextResponse.json({ error: 'permissions must be an array' }, { status: 400 })
}
const validAccessLevels = new Set(['read', 'write'])
for (const item of body.permissions) {
if (
typeof item !== 'object' ||
item === null ||
typeof (item as Record<string, unknown>).libraryId !== 'string' ||
!validAccessLevels.has((item as Record<string, unknown>).accessLevel as string)
) {
return NextResponse.json(
{ error: 'Each permission must have libraryId (string) and accessLevel ("read" | "write")' },
{ status: 400 }
)
}
}
const permissions = body.permissions as LibraryPermission[]
const allLibraries = getLibraries()
const validIds = new Set(allLibraries.map((l) => l.id))
const invalid = body.libraryIds.filter((id) => !validIds.has(id))
const invalid = permissions.filter((p) => !validIds.has(p.libraryId)).map((p) => p.libraryId)
if (invalid.length > 0) {
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
}
setLibraryPermissions(id, body.libraryIds)
setLibraryPermissions(id, permissions)
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,7 +1,7 @@
import { getLibrary } from '@/lib/libraries'
import { notFound, redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { getPermittedLibraryIds } from '@/lib/users'
import { getLibraryAccessLevel } from '@/lib/users'
import GamesView from '@/components/games/GamesView'
import MixedView from '@/components/mixed/MixedView'
import MoviesView from '@/components/movies/MoviesView'
@@ -23,9 +23,11 @@ export default async function LibraryPage({ params, searchParams }: Props) {
const library = getLibrary(id)
if (!library) notFound()
let readOnly = false
if (session.role !== 'admin') {
const permitted = getPermittedLibraryIds(session.userId)
if (!permitted.includes(id)) notFound()
const accessLevel = getLibraryAccessLevel(session.userId, id)
if (!accessLevel) notFound()
readOnly = accessLevel === 'read'
}
return (
@@ -52,10 +54,10 @@ export default async function LibraryPage({ params, searchParams }: Props) {
</div>
)}
{library.type === 'games' && <GamesView libraryId={id} />}
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} />}
{library.type === 'movies' && <MoviesView libraryId={id} />}
{library.type === 'tv' && <TvView libraryId={id} />}
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
{library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />}
</div>
)
}

View File

@@ -216,32 +216,39 @@ function UserRow({
// ─── Permissions Panel ────────────────────────────────────────────────────────
type AccessLevel = 'none' | 'read' | 'write'
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
const [permitted, setPermitted] = useState<string[]>([])
const [levels, setLevels] = useState<Record<string, AccessLevel>>({})
const [saving, setSaving] = useState(false)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
.then((r) => r.json())
.then((data: { libraryIds: string[] }) => {
setPermitted(data.libraryIds)
.then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => {
const map: Record<string, AccessLevel> = {}
for (const p of data.permissions) {
map[p.libraryId] = p.accessLevel
}
setLevels(map)
setLoaded(true)
})
}, [userId])
const toggle = (libraryId: string) => {
setPermitted((prev) =>
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId]
)
const setLevel = (libraryId: string, level: AccessLevel) => {
setLevels((prev) => ({ ...prev, [libraryId]: level }))
}
const save = async () => {
setSaving(true)
const permissions = Object.entries(levels)
.filter(([, level]) => level !== 'none')
.map(([libraryId, accessLevel]) => ({ libraryId, accessLevel }))
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryIds: permitted }),
body: JSON.stringify({ permissions }),
})
setSaving(false)
}
@@ -265,23 +272,40 @@ function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Li
{libraries.length === 0 ? (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
) : (
<div className="space-y-1.5">
{libraries.map((lib) => (
<label key={lib.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={permitted.includes(lib.id)}
onChange={() => toggle(lib.id)}
className="rounded"
/>
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
{lib.name}
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
({lib.type})
</span>
</label>
))}
<div className="space-y-2">
{libraries.map((lib) => {
const current = levels[lib.id] ?? 'none'
return (
<div key={lib.id} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
{lib.name}
</span>
<span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}>
({lib.type})
</span>
</div>
<div
className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium"
style={{ border: '1px solid var(--border)' }}
>
{(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => (
<button
key={lvl}
onClick={() => setLevel(lib.id, lvl)}
className="px-2.5 py-1 transition-colors capitalize"
style={{
backgroundColor: current === lvl ? 'var(--accent)' : 'transparent',
color: current === lvl ? 'var(--background)' : 'var(--text-secondary)',
}}
>
{lvl}
</button>
))}
</div>
</div>
)
})}
</div>
)}
<button

View File

@@ -35,9 +35,10 @@ interface Props {
onTagsChanged?: () => void
onCoverUploaded?: () => void
onDeleted?: (gameId: string) => void
readOnly?: boolean
}
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const screenshotInputRef = useRef<HTMLInputElement>(null)
@@ -217,7 +218,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
</h2>
{/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => setMenuOpen((o) => !o)}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
@@ -269,7 +270,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
)}
</div>
)}
</div>
</div>}
</div>
{/* AI description (read-only) */}
@@ -532,6 +533,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</div>

View File

@@ -58,9 +58,10 @@ function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
interface Props {
libraryId: string
readOnly?: boolean
}
export default function GamesView({ libraryId }: Props) {
export default function GamesView({ libraryId, readOnly }: Props) {
const [items, setItems] = useState<(Game | GameSeries)[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -237,6 +238,7 @@ export default function GamesView({ libraryId }: Props) {
<GameDetailModal
game={selected}
libraryId={libraryId}
readOnly={readOnly}
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }

View File

@@ -14,9 +14,10 @@ interface Props {
onAiTag?: () => Promise<void>
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
}
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange }: Props) {
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal
@@ -354,7 +355,8 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={onAiTag}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
>
{/* Description section */}
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>

View File

@@ -13,6 +13,7 @@ interface Props {
libraryId: string
libraryName: string
initialPath: string
readOnly?: boolean
}
type ModalState =
@@ -22,7 +23,7 @@ type ModalState =
type TagPanelState = { entry: FileEntry; itemKey: string } | null
export default function MixedView({ libraryId, libraryName, initialPath }: Props) {
export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath)
const [listing, setListing] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(true)
@@ -550,7 +551,8 @@ export default function MixedView({ libraryId, libraryName, initialPath }: Props
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
onAiTag={modal.itemKey ? async () => {
readOnly={readOnly}
onAiTag={!readOnly && modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -576,7 +578,8 @@ export default function MixedView({ libraryId, libraryName, initialPath }: Props
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags}
onShowTagsChange={setModalShowTags}
onAiTag={async () => {
readOnly={readOnly}
onAiTag={readOnly ? undefined : async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -16,9 +16,10 @@ interface Props {
context?: 'mixed' | 'movies' | 'tv'
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
}
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange }: Props) {
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
@@ -143,7 +144,8 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={onAiTag}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
/>
)}
</div>

View File

@@ -15,9 +15,10 @@ interface Props {
onTagsChanged?: () => void
onDeleted: (movieId: string) => void
onMetadataRefreshed?: () => void
readOnly?: boolean
}
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) {
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false)
@@ -238,7 +239,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</span>
)}
{/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
@@ -293,7 +294,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
</button>
</div>
)}
</div>
</div>}
</div>
{/* Rename inline input */}
@@ -572,6 +573,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</div>

View File

@@ -9,9 +9,10 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
readOnly?: boolean
}
export default function MoviesView({ libraryId }: Props) {
export default function MoviesView({ libraryId, readOnly }: Props) {
const [movies, setMovies] = useState<Movie[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -203,6 +204,7 @@ export default function MoviesView({ libraryId }: Props) {
<MovieDetailModal
movie={selected}
libraryId={libraryId}
readOnly={readOnly}
onClose={() => setSelectedIndex(null)}
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}

View File

@@ -12,6 +12,7 @@ interface Props {
onAiTag?: () => Promise<void>
disabled?: boolean
disabledMessage?: string
readOnly?: boolean
children?: React.ReactNode
}
@@ -26,6 +27,7 @@ export default function MediaTagPanel({
onAiTag,
disabled,
disabledMessage,
readOnly,
children,
}: Props) {
const [aiTagging, setAiTagging] = useState(false)
@@ -126,6 +128,7 @@ export default function MediaTagPanel({
onTagsChanged={onTagsChanged}
refreshKey={internalRefreshKey + externalRefreshKey}
hideDescription
readOnly={readOnly}
/>
</>
)}

View File

@@ -9,6 +9,7 @@ interface Props {
onTagsChanged?: () => void
refreshKey?: number
hideDescription?: boolean
readOnly?: boolean
}
interface AllTags {
@@ -16,7 +17,7 @@ interface AllTags {
tags: Tag[]
}
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription }: Props) {
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription, readOnly }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [],
categories: [],
@@ -277,23 +278,25 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
style={{ backgroundColor: 'var(--surface-hover)' }}
>
{tag.name}
<button
onClick={() => toggleTag(tag)}
className="ml-0.5 leading-none transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
aria-label={`Remove tag ${tag.name}`}
>
</button>
{!readOnly && (
<button
onClick={() => toggleTag(tag)}
className="ml-0.5 leading-none transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
aria-label={`Remove tag ${tag.name}`}
>
</button>
)}
</span>
))}
</span>
)
})}
{ungrouped.map((tag) => (
<TagBadge key={tag.id} tag={tag} onRemove={() => toggleTag(tag)} />
<TagBadge key={tag.id} tag={tag} onRemove={readOnly ? undefined : () => toggleTag(tag)} />
))}
</>
)
@@ -302,7 +305,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
)}
{/* Tag picker grouped by category */}
<div className="flex flex-col gap-2">
{!readOnly && <div className="flex flex-col gap-2">
{all.categories.map((category) => {
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const search = categorySearches[category.id] ?? ''
@@ -531,7 +534,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
</button>
)}
</div>
</div>
</div>}
</div>
)
}

View File

@@ -14,11 +14,12 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
readOnly?: boolean
}
type ViewLevel = 'series' | 'seasons' | 'episodes'
export default function TvView({ libraryId }: Props) {
export default function TvView({ libraryId, readOnly }: Props) {
const [view, setView] = useState<ViewLevel>('series')
const [series, setSeries] = useState<TvSeries[]>([])
const [seasons, setSeasons] = useState<TvSeason[]>([])
@@ -434,6 +435,7 @@ export default function TvView({ libraryId }: Props) {
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
context="tv"
readOnly={readOnly}
/>
)
}
@@ -1000,7 +1002,7 @@ export default function TvView({ libraryId }: Props) {
{/* Floating controls — tag + close */}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && (
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
<button
onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }}
className={smallBtn}
@@ -1077,6 +1079,7 @@ export default function TvView({ libraryId }: Props) {
externalRefreshKey={tagRefreshKey}
disabled={tagPanelDisabled}
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
readOnly={readOnly}
/>
)}
</div>

View File

@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
}
// Auth guard result type
type AuthSuccess = { session: IronSession<SessionData> }
type AuthSuccess = { session: IronSession<SessionData>; accessLevel?: 'admin' | 'write' | 'read' }
type AuthResult = AuthSuccess | NextResponse
// Read-only session from an API route request (throwaway response)
@@ -100,13 +100,22 @@ export async function requireLibraryAccess(req: NextRequest, libraryId: string):
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (session.role === 'admin') return { session }
if (session.role === 'admin') return { session, accessLevel: 'admin' }
// Lazy import to avoid pulling DB into edge contexts
const { getPermittedLibraryIds } = await import('./users')
const permitted = getPermittedLibraryIds(session.userId)
if (!permitted.includes(libraryId)) {
const { getLibraryAccessLevel } = await import('./users')
const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
if (!accessLevel) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return { session }
return { session, accessLevel }
}
export async function requireLibraryWriteAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
const result = await requireLibraryAccess(req, libraryId)
if (result instanceof NextResponse) return result
if (result.accessLevel === 'read') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return result
}

View File

@@ -106,6 +106,7 @@ function initDb(db: Database.Database): void {
migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db)
migrateAiJobs(db)
migrateLibraryPermissionsAccessLevel(db)
seedAppSettings(db)
}
@@ -318,6 +319,15 @@ function migrateLibrariesType(db: Database.Database): void {
}
}
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('access_level')) {
db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`)
}
}
function migrateAiJobs(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS ai_jobs (

View File

@@ -77,43 +77,60 @@ export function listUsers(): User[] {
}))
}
export function getPermittedLibraryIds(userId: string): string[] {
const db = getDb()
const rows = db
.prepare('SELECT library_id FROM library_permissions WHERE user_id = ?')
.all(userId) as { library_id: string }[]
return rows.map((r) => r.library_id)
export interface LibraryPermission {
libraryId: string
accessLevel: 'read' | 'write'
}
export function setLibraryPermissions(userId: string, libraryIds: string[]): void {
export function getLibraryPermissions(userId: string): LibraryPermission[] {
const db = getDb()
const rows = db
.prepare('SELECT library_id, access_level FROM library_permissions WHERE user_id = ?')
.all(userId) as { library_id: string; access_level: string }[]
return rows.map((r) => ({ libraryId: r.library_id, accessLevel: r.access_level as 'read' | 'write' }))
}
export function getLibraryAccessLevel(userId: string, libraryId: string): 'read' | 'write' | null {
const db = getDb()
const row = db
.prepare('SELECT access_level FROM library_permissions WHERE user_id = ? AND library_id = ?')
.get(userId, libraryId) as { access_level: string } | undefined
if (!row) return null
return row.access_level as 'read' | 'write'
}
export function setLibraryPermissions(userId: string, permissions: LibraryPermission[]): void {
const db = getDb()
const tx = db.transaction(() => {
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
const insert = db.prepare('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)')
for (const libraryId of libraryIds) {
insert.run(userId, libraryId)
const insert = db.prepare(
'INSERT INTO library_permissions (user_id, library_id, access_level) VALUES (?, ?, ?)'
)
for (const { libraryId, accessLevel } of permissions) {
insert.run(userId, libraryId, accessLevel)
}
})
tx()
}
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
if (role === 'admin') return getLibraries()
if (role === 'admin') return getLibraries().map((l) => ({ ...l, accessLevel: 'admin' as const }))
const db = getDb()
const rows = db
.prepare(
`SELECT l.id, l.name, l.path, l.type, l.cover_ext
`SELECT l.id, l.name, l.path, l.type, l.cover_ext, lp.access_level
FROM libraries l
INNER JOIN library_permissions lp ON lp.library_id = l.id
WHERE lp.user_id = ?
ORDER BY l.name ASC`
)
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null }[]
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null; access_level: string }[]
return rows.map((r) => ({
id: r.id,
name: r.name,
path: r.path,
type: r.type as Library['type'],
coverExt: r.cover_ext,
accessLevel: r.access_level as 'read' | 'write',
}))
}

View File

@@ -6,6 +6,7 @@ export interface Library {
path: string
type: LibraryType
coverExt: string | null
accessLevel?: 'admin' | 'read' | 'write'
}
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'