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