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 { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs' import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) 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 }) 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 if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS) const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)

View File

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

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs' import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) 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 }) 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 if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS) const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth' 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' import { getLibraries } from '@/lib/libraries'
export async function GET( export async function GET(
@@ -17,8 +17,8 @@ export async function GET(
return NextResponse.json({ error: 'User not found' }, { status: 404 }) return NextResponse.json({ error: 'User not found' }, { status: 404 })
} }
const libraryIds = getPermittedLibraryIds(id) const permissions = getLibraryPermissions(id)
return NextResponse.json({ libraryIds }) return NextResponse.json({ permissions })
} }
export async function PUT( export async function PUT(
@@ -35,24 +35,41 @@ export async function PUT(
return NextResponse.json({ error: 'User not found' }, { status: 404 }) return NextResponse.json({ error: 'User not found' }, { status: 404 })
} }
let body: { libraryIds?: unknown } let body: { permissions?: unknown }
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
} }
if (!Array.isArray(body.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) { if (!Array.isArray(body.permissions)) {
return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 }) 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 allLibraries = getLibraries()
const validIds = new Set(allLibraries.map((l) => l.id)) 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) { if (invalid.length > 0) {
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 }) return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
} }
setLibraryPermissions(id, body.libraryIds) setLibraryPermissions(id, permissions)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,10 @@ interface Props {
onAiTag?: () => Promise<void> onAiTag?: () => Promise<void>
showTags?: boolean showTags?: boolean
onShowTagsChange?: (v: boolean) => void 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 overlayRef = useRef<HTMLDivElement>(null)
const [showTagsLocal, setShowTagsLocal] = useState(false) const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal const showTags = showTagsProp ?? showTagsLocal
@@ -354,7 +355,8 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
onHide={() => setShowTags(false)} onHide={() => setShowTags(false)}
onClose={onClose} onClose={onClose}
onTagsChanged={onTagsChanged} onTagsChanged={onTagsChanged}
onAiTag={onAiTag} onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
> >
{/* Description section */} {/* Description section */}
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}> <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 libraryId: string
libraryName: string libraryName: string
initialPath: string initialPath: string
readOnly?: boolean
} }
type ModalState = type ModalState =
@@ -22,7 +23,7 @@ type ModalState =
type TagPanelState = { entry: FileEntry; itemKey: string } | null 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 [currentPath, setCurrentPath] = useState(initialPath)
const [listing, setListing] = useState<DirectoryListing | null>(null) const [listing, setListing] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(true) 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} onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags} showTags={modalShowTags}
onShowTagsChange={setModalShowTags} onShowTagsChange={setModalShowTags}
onAiTag={modal.itemKey ? async () => { readOnly={readOnly}
onAiTag={!readOnly && modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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} onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
showTags={modalShowTags} showTags={modalShowTags}
onShowTagsChange={setModalShowTags} onShowTagsChange={setModalShowTags}
onAiTag={async () => { readOnly={readOnly}
onAiTag={readOnly ? undefined : async () => {
const res = await fetch('/api/ai-tagging', { const res = await fetch('/api/ai-tagging', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -16,9 +16,10 @@ interface Props {
context?: 'mixed' | 'movies' | 'tv' context?: 'mixed' | 'movies' | 'tv'
showTags?: boolean showTags?: boolean
onShowTagsChange?: (v: boolean) => void 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 settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop 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)} onHide={() => setShowTags(false)}
onClose={onClose} onClose={onClose}
onTagsChanged={onTagsChanged} onTagsChanged={onTagsChanged}
onAiTag={onAiTag} onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
/> />
)} )}
</div> </div>

View File

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

View File

@@ -9,9 +9,10 @@ import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
readOnly?: boolean
} }
export default function MoviesView({ libraryId }: Props) { export default function MoviesView({ libraryId, readOnly }: Props) {
const [movies, setMovies] = useState<Movie[]>([]) const [movies, setMovies] = useState<Movie[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -203,6 +204,7 @@ export default function MoviesView({ libraryId }: Props) {
<MovieDetailModal <MovieDetailModal
movie={selected} movie={selected}
libraryId={libraryId} libraryId={libraryId}
readOnly={readOnly}
onClose={() => setSelectedIndex(null)} onClose={() => setSelectedIndex(null)}
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined} onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={selectedIndex < filtered.length - 1 ? () => 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> onAiTag?: () => Promise<void>
disabled?: boolean disabled?: boolean
disabledMessage?: string disabledMessage?: string
readOnly?: boolean
children?: React.ReactNode children?: React.ReactNode
} }
@@ -26,6 +27,7 @@ export default function MediaTagPanel({
onAiTag, onAiTag,
disabled, disabled,
disabledMessage, disabledMessage,
readOnly,
children, children,
}: Props) { }: Props) {
const [aiTagging, setAiTagging] = useState(false) const [aiTagging, setAiTagging] = useState(false)
@@ -126,6 +128,7 @@ export default function MediaTagPanel({
onTagsChanged={onTagsChanged} onTagsChanged={onTagsChanged}
refreshKey={internalRefreshKey + externalRefreshKey} refreshKey={internalRefreshKey + externalRefreshKey}
hideDescription hideDescription
readOnly={readOnly}
/> />
</> </>
)} )}

View File

@@ -9,6 +9,7 @@ interface Props {
onTagsChanged?: () => void onTagsChanged?: () => void
refreshKey?: number refreshKey?: number
hideDescription?: boolean hideDescription?: boolean
readOnly?: boolean
} }
interface AllTags { interface AllTags {
@@ -16,7 +17,7 @@ interface AllTags {
tags: Tag[] 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[] }>({ const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [], tags: [],
categories: [], categories: [],
@@ -277,6 +278,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
style={{ backgroundColor: 'var(--surface-hover)' }} style={{ backgroundColor: 'var(--surface-hover)' }}
> >
{tag.name} {tag.name}
{!readOnly && (
<button <button
onClick={() => toggleTag(tag)} onClick={() => toggleTag(tag)}
className="ml-0.5 leading-none transition-colors" className="ml-0.5 leading-none transition-colors"
@@ -287,13 +289,14 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
> >
</button> </button>
)}
</span> </span>
))} ))}
</span> </span>
) )
})} })}
{ungrouped.map((tag) => ( {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 */} {/* Tag picker grouped by category */}
<div className="flex flex-col gap-2"> {!readOnly && <div className="flex flex-col gap-2">
{all.categories.map((category) => { {all.categories.map((category) => {
const categoryTags = all.tags.filter((t) => t.categoryId === category.id) const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const search = categorySearches[category.id] ?? '' const search = categorySearches[category.id] ?? ''
@@ -531,7 +534,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
</button> </button>
)} )}
</div> </div>
</div> </div>}
</div> </div>
) )
} }

View File

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

View File

@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
} }
// Auth guard result type // Auth guard result type
type AuthSuccess = { session: IronSession<SessionData> } type AuthSuccess = { session: IronSession<SessionData>; accessLevel?: 'admin' | 'write' | 'read' }
type AuthResult = AuthSuccess | NextResponse type AuthResult = AuthSuccess | NextResponse
// Read-only session from an API route request (throwaway response) // 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) { if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 // Lazy import to avoid pulling DB into edge contexts
const { getPermittedLibraryIds } = await import('./users') const { getLibraryAccessLevel } = await import('./users')
const permitted = getPermittedLibraryIds(session.userId) const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
if (!permitted.includes(libraryId)) { if (!accessLevel) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) 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) migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db) migrateLibraryAiSettings(db)
migrateAiJobs(db) migrateAiJobs(db)
migrateLibraryPermissionsAccessLevel(db)
seedAppSettings(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 { function migrateAiJobs(db: Database.Database): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS ai_jobs ( CREATE TABLE IF NOT EXISTS ai_jobs (

View File

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

View File

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