From 2ea02b197b02dfee258be06bca5a7c277dda7495 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:48:01 -0400 Subject: [PATCH] expand user permissions --- src/app/api/ai-tagging/describe-bulk/route.ts | 4 +- src/app/api/ai-tagging/describe/route.ts | 4 +- .../api/ai-tagging/extract-text-bulk/route.ts | 4 +- src/app/api/ai-tagging/extract-text/route.ts | 4 +- src/app/api/ai-tagging/fields/route.ts | 4 +- src/app/api/ai-tagging/route.ts | 4 +- .../api/ai-tagging/translate-bulk/route.ts | 4 +- src/app/api/ai-tagging/translate/route.ts | 4 +- src/app/api/libraries/route.ts | 2 +- src/app/api/tags/assignments/route.ts | 6 +- src/app/api/users/[id]/permissions/route.ts | 33 +++++++-- src/app/library/[id]/page.tsx | 16 ++-- src/app/manage/users/page.tsx | 74 ++++++++++++------- src/components/games/GameDetailModal.tsx | 8 +- src/components/games/GamesView.tsx | 4 +- src/components/mixed/ImageLightbox.tsx | 6 +- src/components/mixed/MixedView.tsx | 9 ++- src/components/mixed/VideoPlayerModal.tsx | 6 +- src/components/movies/MovieDetailModal.tsx | 8 +- src/components/movies/MoviesView.tsx | 4 +- src/components/tags/MediaTagPanel.tsx | 3 + src/components/tags/TagSelector.tsx | 31 ++++---- src/components/tv/TvView.tsx | 7 +- src/lib/auth.ts | 21 ++++-- src/lib/db.ts | 10 +++ src/lib/users.ts | 43 +++++++---- src/types/index.ts | 1 + 27 files changed, 214 insertions(+), 110 deletions(-) diff --git a/src/app/api/ai-tagging/describe-bulk/route.ts b/src/app/api/ai-tagging/describe-bulk/route.ts index 35fc2ba..26dac78 100644 --- a/src/app/api/ai-tagging/describe-bulk/route.ts +++ b/src/app/api/ai-tagging/describe-bulk/route.ts @@ -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) diff --git a/src/app/api/ai-tagging/describe/route.ts b/src/app/api/ai-tagging/describe/route.ts index ba6e508..d6268e0 100644 --- a/src/app/api/ai-tagging/describe/route.ts +++ b/src/app/api/ai-tagging/describe/route.ts @@ -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) diff --git a/src/app/api/ai-tagging/extract-text-bulk/route.ts b/src/app/api/ai-tagging/extract-text-bulk/route.ts index 3004abd..e0f5429 100644 --- a/src/app/api/ai-tagging/extract-text-bulk/route.ts +++ b/src/app/api/ai-tagging/extract-text-bulk/route.ts @@ -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) diff --git a/src/app/api/ai-tagging/extract-text/route.ts b/src/app/api/ai-tagging/extract-text/route.ts index 857f710..1be8365 100644 --- a/src/app/api/ai-tagging/extract-text/route.ts +++ b/src/app/api/ai-tagging/extract-text/route.ts @@ -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 = {} diff --git a/src/app/api/ai-tagging/fields/route.ts b/src/app/api/ai-tagging/fields/route.ts index db65d63..b173671 100644 --- a/src/app/api/ai-tagging/fields/route.ts +++ b/src/app/api/ai-tagging/fields/route.ts @@ -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) { diff --git a/src/app/api/ai-tagging/route.ts b/src/app/api/ai-tagging/route.ts index 428b701..7f24047 100644 --- a/src/app/api/ai-tagging/route.ts +++ b/src/app/api/ai-tagging/route.ts @@ -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) diff --git a/src/app/api/ai-tagging/translate-bulk/route.ts b/src/app/api/ai-tagging/translate-bulk/route.ts index 7b10fa1..20ac605 100644 --- a/src/app/api/ai-tagging/translate-bulk/route.ts +++ b/src/app/api/ai-tagging/translate-bulk/route.ts @@ -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() diff --git a/src/app/api/ai-tagging/translate/route.ts b/src/app/api/ai-tagging/translate/route.ts index 27ee8dd..9afeb2a 100644 --- a/src/app/api/ai-tagging/translate/route.ts +++ b/src/app/api/ai-tagging/translate/route.ts @@ -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) diff --git a/src/app/api/libraries/route.ts b/src/app/api/libraries/route.ts index c09eec1..c4eb58d 100644 --- a/src/app/api/libraries/route.ts +++ b/src/app/api/libraries/route.ts @@ -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) { diff --git a/src/app/api/tags/assignments/route.ts b/src/app/api/tags/assignments/route.ts index 1b6ddb5..0f528f5 100644 --- a/src/app/api/tags/assignments/route.ts +++ b/src/app/api/tags/assignments/route.ts @@ -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) diff --git a/src/app/api/users/[id]/permissions/route.ts b/src/app/api/users/[id]/permissions/route.ts index 7c3f021..ae82a67 100644 --- a/src/app/api/users/[id]/permissions/route.ts +++ b/src/app/api/users/[id]/permissions/route.ts @@ -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).libraryId !== 'string' || + !validAccessLevels.has((item as Record).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 }) } diff --git a/src/app/library/[id]/page.tsx b/src/app/library/[id]/page.tsx index 3ba1d1c..f5aaf36 100644 --- a/src/app/library/[id]/page.tsx +++ b/src/app/library/[id]/page.tsx @@ -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) { )} - {library.type === 'games' && } - {library.type === 'mixed' && } - {library.type === 'movies' && } - {library.type === 'tv' && } + {library.type === 'games' && } + {library.type === 'mixed' && } + {library.type === 'movies' && } + {library.type === 'tv' && } ) } diff --git a/src/app/manage/users/page.tsx b/src/app/manage/users/page.tsx index 3a5790e..3bd411f 100644 --- a/src/app/manage/users/page.tsx +++ b/src/app/manage/users/page.tsx @@ -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([]) + const [levels, setLevels] = useState>({}) 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 = {} + 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 ? (

No libraries configured.

) : ( -
- {libraries.map((lib) => ( - - ))} +
+ {libraries.map((lib) => { + const current = levels[lib.id] ?? 'none' + return ( +
+
+ + {lib.name} + + + ({lib.type}) + +
+
+ {(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => ( + + ))} +
+
+ ) + })}
)}
)} - + } {/* 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} /> )} diff --git a/src/components/games/GamesView.tsx b/src/components/games/GamesView.tsx index 24e7921..041b9bc 100644 --- a/src/components/games/GamesView.tsx +++ b/src/components/games/GamesView.tsx @@ -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(null) @@ -237,6 +238,7 @@ export default function GamesView({ libraryId }: Props) { { setSelected(null); setSelectedGameIndex(null) }} onPrev={selectedGameIndex !== null && selectedGameIndex > 0 ? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) } diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index 5a1197a..35900ec 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -14,9 +14,10 @@ interface Props { onAiTag?: () => Promise 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(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 */}
diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 4fd7c4b..8e393e4 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -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(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' }, diff --git a/src/components/mixed/VideoPlayerModal.tsx b/src/components/mixed/VideoPlayerModal.tsx index 9309943..82e3ca4 100644 --- a/src/components/mixed/VideoPlayerModal.tsx +++ b/src/components/mixed/VideoPlayerModal.tsx @@ -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} /> )}
diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx index e18c091..a424ec6 100644 --- a/src/components/movies/MovieDetailModal.tsx +++ b/src/components/movies/MovieDetailModal.tsx @@ -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(null) const menuRef = useRef(null) const [playing, setPlaying] = useState(false) @@ -238,7 +239,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on )} {/* Kebab menu */} -
+ {!readOnly &&
)} -
+ } {/* 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} /> )} diff --git a/src/components/movies/MoviesView.tsx b/src/components/movies/MoviesView.tsx index 64f87c6..577467b 100644 --- a/src/components/movies/MoviesView.tsx +++ b/src/components/movies/MoviesView.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -203,6 +204,7 @@ export default function MoviesView({ libraryId }: Props) { 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} diff --git a/src/components/tags/MediaTagPanel.tsx b/src/components/tags/MediaTagPanel.tsx index 6111965..4ee8c89 100644 --- a/src/components/tags/MediaTagPanel.tsx +++ b/src/components/tags/MediaTagPanel.tsx @@ -12,6 +12,7 @@ interface Props { onAiTag?: () => Promise 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} /> )} diff --git a/src/components/tags/TagSelector.tsx b/src/components/tags/TagSelector.tsx index 7d3a756..1c353d7 100644 --- a/src/components/tags/TagSelector.tsx +++ b/src/components/tags/TagSelector.tsx @@ -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} - + {!readOnly && ( + + )} ))} ) })} {ungrouped.map((tag) => ( - toggleTag(tag)} /> + toggleTag(tag)} /> ))} ) @@ -302,7 +305,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe )} {/* Tag picker grouped by category */} -
+ {!readOnly &&
{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 )}
-
+ } ) } diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx index b3f8f47..eb6cf0e 100644 --- a/src/components/tv/TvView.tsx +++ b/src/components/tv/TvView.tsx @@ -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('series') const [series, setSeries] = useState([]) const [seasons, setSeasons] = useState([]) @@ -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 */}
e.stopPropagation()}> - {view === 'seasons' && selectedSeries?.item_key && !showTagPanel && ( + {view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 3385ddf..6724c0b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise } +type AuthSuccess = { session: IronSession; 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 { + 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 } diff --git a/src/lib/db.ts b/src/lib/db.ts index bb6d2a4..da3f9eb 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 ( diff --git a/src/lib/users.ts b/src/lib/users.ts index 63eacc4..c88aeed 100644 --- a/src/lib/users.ts +++ b/src/lib/users.ts @@ -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', })) } diff --git a/src/types/index.ts b/src/types/index.ts index fcf8b88..718a17e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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'