add auth
This commit is contained in:
36
src/app/api/auth/login/route.ts
Normal file
36
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getWritableSession, verifyPassword, type SessionData } from '@/lib/auth'
|
||||
import { getUserByUsername } from '@/lib/users'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { username?: string; password?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { username, password } = body
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = getUserByUsername(username)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ role: user.role })
|
||||
const session = await getWritableSession(request, response)
|
||||
session.userId = user.id
|
||||
session.username = user.username
|
||||
session.role = user.role
|
||||
await session.save()
|
||||
|
||||
return response
|
||||
}
|
||||
9
src/app/api/auth/logout/route.ts
Normal file
9
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getWritableSession } from '@/lib/auth'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const response = new NextResponse(null, { status: 204 })
|
||||
const session = await getWritableSession(request, response)
|
||||
session.destroy()
|
||||
return response
|
||||
}
|
||||
59
src/app/api/auth/register/route.ts
Normal file
59
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSessionOptions, hashPassword, type SessionData } from '@/lib/auth'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { getUserCount, createUser } from '@/lib/users'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { username?: string; password?: string; role?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { username, password } = body
|
||||
let { role } = body
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 })
|
||||
}
|
||||
if (username.trim().length < 2) {
|
||||
return NextResponse.json({ error: 'Username must be at least 2 characters' }, { status: 400 })
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userCount = getUserCount()
|
||||
|
||||
if (userCount === 0) {
|
||||
// First user always becomes admin
|
||||
role = 'admin'
|
||||
} else {
|
||||
// Subsequent users require an admin session
|
||||
const res = new NextResponse()
|
||||
const session = await getIronSession<SessionData>(request, res, getSessionOptions())
|
||||
if (!session.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
if (role !== 'admin' && role !== 'user') {
|
||||
role = 'user'
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
try {
|
||||
const user = createUser(username.trim(), passwordHash, role as 'admin' | 'user')
|
||||
return NextResponse.json({ id: user.id, username: user.username, role: user.role }, { status: 201 })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create user'
|
||||
if (message.includes('UNIQUE constraint failed')) {
|
||||
return NextResponse.json({ error: 'Username already taken' }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
||||
import { scanDirectory } from '@/lib/files'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const subpath = searchParams.get('path') ?? ''
|
||||
@@ -11,6 +12,9 @@ export function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.mp4': 'video/mp4',
|
||||
@@ -35,6 +36,9 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
|
||||
}
|
||||
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'fs'
|
||||
import sharp from 'sharp'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
|
||||
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
@@ -13,6 +14,9 @@ function isCoverType(s: string | null): s is CoverType {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const itemId = searchParams.get('itemId')
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
||||
import { scanGamesLibrary } from '@/lib/games'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
|
||||
@@ -10,6 +11,9 @@ export function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, removeLibrary } from '@/lib/libraries'
|
||||
import { removeAllAssignmentsForLibrary } from '@/lib/tags'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const library = getLibrary(id)
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibraries, addLibrary } from '@/lib/libraries'
|
||||
import { getLibrariesForUser } from '@/lib/users'
|
||||
import { requireAuth, requireAdmin } from '@/lib/auth'
|
||||
import type { LibraryType } from '@/types'
|
||||
|
||||
export function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAuth(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { session } = auth
|
||||
|
||||
try {
|
||||
const libraries = getLibraries()
|
||||
const libraries =
|
||||
session.role === 'admin'
|
||||
? getLibraries()
|
||||
: getLibrariesForUser(session.userId, session.role)
|
||||
return NextResponse.json(libraries)
|
||||
} catch (err) {
|
||||
console.error('Failed to read libraries', err)
|
||||
@@ -13,6 +22,9 @@ export function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
let body: { name?: string; path?: string; type?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'fs'
|
||||
import sharp from 'sharp'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, updateLibraryCover, clearLibraryCover } from '@/lib/libraries'
|
||||
import { requireAuth, requireAdmin } from '@/lib/auth'
|
||||
|
||||
const COVERS_DIR = path.resolve(process.cwd(), '.covers')
|
||||
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
|
||||
@@ -12,9 +13,12 @@ function coverPath(id: string, ext: string) {
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAuth(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { id } = await params
|
||||
const library = getLibrary(id)
|
||||
if (!library?.coverExt) {
|
||||
@@ -39,6 +43,9 @@ export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { id } = await params
|
||||
const library = getLibrary(id)
|
||||
if (!library) {
|
||||
@@ -85,9 +92,12 @@ export async function POST(
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { id } = await params
|
||||
const library = getLibrary(id)
|
||||
if (!library) {
|
||||
|
||||
@@ -4,8 +4,9 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
import { scanMoviesLibrary } from '@/lib/movies'
|
||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
|
||||
@@ -13,6 +14,9 @@ export function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
@@ -26,7 +30,10 @@ export function GET(request: NextRequest) {
|
||||
return NextResponse.json(movies)
|
||||
}
|
||||
|
||||
export function DELETE(request: NextRequest) {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const movieId = searchParams.get('movieId')
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
function extractLibraryId(mediaKey: string): string | null {
|
||||
const colonIdx = mediaKey.indexOf(':')
|
||||
if (colonIdx === -1) return null
|
||||
return mediaKey.slice(0, colonIdx)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -8,6 +15,13 @@ export async function GET(request: NextRequest) {
|
||||
if (!mediaKey) {
|
||||
return NextResponse.json({ error: 'mediaKey is required' }, { status: 400 })
|
||||
}
|
||||
const libraryId = extractLibraryId(mediaKey)
|
||||
if (!libraryId) {
|
||||
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 })
|
||||
}
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
return NextResponse.json(getResolvedTagsForItem(mediaKey))
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||
@@ -20,6 +34,13 @@ export async function POST(request: NextRequest) {
|
||||
if (!mediaKey || !tagId) {
|
||||
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 })
|
||||
}
|
||||
const libraryId = extractLibraryId(mediaKey)
|
||||
if (!libraryId) {
|
||||
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 })
|
||||
}
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
addTagToItem(mediaKey, tagId)
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (err) {
|
||||
@@ -35,6 +56,13 @@ export async function DELETE(request: NextRequest) {
|
||||
if (!mediaKey || !tagId) {
|
||||
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 })
|
||||
}
|
||||
const libraryId = extractLibraryId(mediaKey)
|
||||
if (!libraryId) {
|
||||
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 })
|
||||
}
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
removeTagFromItem(mediaKey, tagId)
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const { name } = await request.json()
|
||||
@@ -19,6 +23,9 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getCategories, addCategory } from '@/lib/tags'
|
||||
import { requireAuth, requireAdmin } from '@/lib/auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAuth(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(getCategories())
|
||||
} catch (err) {
|
||||
@@ -10,6 +14,9 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const { name } = await request.json()
|
||||
const category = addCategory(name)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { updateTag, deleteTag } from '@/lib/tags'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const { name } = await request.json()
|
||||
@@ -16,9 +20,12 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
deleteTag(id)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTags, getTagsSortedByUsage, addTag } from '@/lib/tags'
|
||||
import { requireAuth, requireAdmin } from '@/lib/auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAuth(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const categoryId = searchParams.get('categoryId') ?? undefined
|
||||
@@ -15,6 +19,9 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const { name, categoryId } = await request.json()
|
||||
const tag = addTag(name, categoryId)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTagAssignmentsForLibrary } from '@/lib/tags'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
if (!libraryId) return Response.json({ error: 'libraryId required' }, { status: 400 })
|
||||
return Response.json(getTagAssignmentsForLibrary(libraryId))
|
||||
if (!libraryId) return NextResponse.json({ error: 'libraryId required' }, { status: 400 })
|
||||
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
return NextResponse.json(getTagAssignmentsForLibrary(libraryId))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
import { getThumbnailPath } from '@/lib/thumbnails'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||
@@ -23,6 +24,9 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
|
||||
}
|
||||
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
|
||||
@@ -4,8 +4,9 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv'
|
||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const seriesId = searchParams.get('seriesId')
|
||||
@@ -15,6 +16,9 @@ export function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
@@ -39,7 +43,10 @@ export function GET(request: NextRequest) {
|
||||
return NextResponse.json(series)
|
||||
}
|
||||
|
||||
export function DELETE(request: NextRequest) {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const seriesId = searchParams.get('seriesId')
|
||||
|
||||
58
src/app/api/users/[id]/permissions/route.ts
Normal file
58
src/app/api/users/[id]/permissions/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
import { getUserById, getPermittedLibraryIds, setLibraryPermissions } from '@/lib/users'
|
||||
import { getLibraries } from '@/lib/libraries'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const user = getUserById(id)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const libraryIds = getPermittedLibraryIds(id)
|
||||
return NextResponse.json({ libraryIds })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const user = getUserById(id)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let body: { libraryIds?: 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 })
|
||||
}
|
||||
|
||||
const allLibraries = getLibraries()
|
||||
const validIds = new Set(allLibraries.map((l) => l.id))
|
||||
const invalid = body.libraryIds.filter((id) => !validIds.has(id))
|
||||
if (invalid.length > 0) {
|
||||
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
|
||||
}
|
||||
|
||||
setLibraryPermissions(id, body.libraryIds)
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
33
src/app/api/users/[id]/route.ts
Normal file
33
src/app/api/users/[id]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
import { getUserById, deleteUser, listUsers } from '@/lib/users'
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { session } = auth
|
||||
|
||||
const { id } = await params
|
||||
|
||||
if (id === session.userId) {
|
||||
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 409 })
|
||||
}
|
||||
|
||||
const target = getUserById(id)
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (target.role === 'admin') {
|
||||
const admins = listUsers().filter((u) => u.role === 'admin')
|
||||
if (admins.length <= 1) {
|
||||
return NextResponse.json({ error: 'Cannot delete the last admin account' }, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
deleteUser(id)
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
10
src/app/api/users/route.ts
Normal file
10
src/app/api/users/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
import { listUsers } from '@/lib/users'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
return NextResponse.json(listUsers())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import NavLink from '@/components/NavLink'
|
||||
import { getServerSession } from '@/lib/auth'
|
||||
import HeaderNav from '@/components/HeaderNav'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -7,11 +8,13 @@ export const metadata: Metadata = {
|
||||
description: 'Your personal media library',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await getServerSession()
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen">
|
||||
@@ -22,7 +25,9 @@ export default function RootLayout({
|
||||
MediaLore
|
||||
</a>
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink href="/manage">Manage</NavLink>
|
||||
{session.userId && (
|
||||
<HeaderNav username={session.username} isAdmin={session.role === 'admin'} />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getLibrary } from '@/lib/libraries'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getServerSession } from '@/lib/auth'
|
||||
import { getPermittedLibraryIds } from '@/lib/users'
|
||||
import GamesView from '@/components/games/GamesView'
|
||||
import MixedView from '@/components/mixed/MixedView'
|
||||
import MoviesView from '@/components/movies/MoviesView'
|
||||
@@ -14,9 +16,17 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
||||
const { id } = await params
|
||||
const { path: subpath } = await searchParams
|
||||
|
||||
const session = await getServerSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
const library = getLibrary(id)
|
||||
if (!library) notFound()
|
||||
|
||||
if (session.role !== 'admin') {
|
||||
const permitted = getPermittedLibraryIds(session.userId)
|
||||
if (!permitted.includes(id)) notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
|
||||
158
src/app/login/LoginForm.tsx
Normal file
158
src/app/login/LoginForm.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
interface Props {
|
||||
isFirstRun: boolean
|
||||
}
|
||||
|
||||
export default function LoginForm({ isFirstRun }: Props) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const from = searchParams.get('from') ?? '/'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (isFirstRun && password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
if (isFirstRun) {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, role: 'admin' }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setError(data.error ?? 'Registration failed')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
if (!loginRes.ok) {
|
||||
const data = await loginRes.json()
|
||||
setError(data.error ?? 'Login failed')
|
||||
return
|
||||
}
|
||||
|
||||
router.push(from)
|
||||
router.refresh()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm px-6">
|
||||
<div className="mb-8 text-center">
|
||||
<span style={{ color: 'var(--accent)', fontSize: '2rem' }}>◈</span>
|
||||
<h1 className="mt-2 text-2xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
MediaLore
|
||||
</h1>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{isFirstRun ? 'Create your admin account to get started' : 'Sign in to your account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete={isFirstRun ? 'new-password' : 'current-password'}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isFirstRun && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm" style={{ color: 'var(--error, #ef4444)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 rounded-lg text-sm font-medium transition-opacity"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent)',
|
||||
color: 'var(--background)',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Please wait…' : isFirstRun ? 'Create Account' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/app/login/layout.tsx
Normal file
16
src/app/login/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next'
|
||||
import '../globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MediaLore — Sign In',
|
||||
}
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen flex items-center justify-center">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
14
src/app/login/page.tsx
Normal file
14
src/app/login/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Suspense } from 'react'
|
||||
import { getUserCount } from '@/lib/users'
|
||||
import LoginForm from './LoginForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function LoginPage() {
|
||||
const isFirstRun = getUserCount() === 0
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm isFirstRun={isFirstRun} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getServerSession } from '@/lib/auth'
|
||||
import ManageSubNav from '@/components/ManageSubNav'
|
||||
|
||||
export default function ManageLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function ManageLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getServerSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
if (session.role !== 'admin') redirect('/')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ManageSubNav />
|
||||
|
||||
436
src/app/manage/users/page.tsx
Normal file
436
src/app/manage/users/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: 'admin' | 'user'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface Library {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = () => {
|
||||
Promise.all([
|
||||
fetch('/api/users').then((r) => r.json()),
|
||||
fetch('/api/libraries').then((r) => r.json()),
|
||||
])
|
||||
.then(([usersData, librariesData]) => {
|
||||
setUsers(usersData)
|
||||
setLibraries(librariesData)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
Manage Users
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
Create users and manage library access permissions.
|
||||
</p>
|
||||
|
||||
<Section title="Users">
|
||||
{loading ? (
|
||||
<LoadingRows />
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
No users found.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{users.map((user) => (
|
||||
<UserRow key={user.id} user={user} libraries={libraries} onChanged={refresh} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Add a User">
|
||||
<AddUserForm onAdded={refresh} />
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<h2
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className="rounded-xl border"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||
>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── User Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
libraries,
|
||||
onChanged,
|
||||
}: {
|
||||
user: User
|
||||
libraries: Library[]
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [showPermissions, setShowPermissions] = useState(false)
|
||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (!confirming) {
|
||||
setConfirming(true)
|
||||
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
|
||||
return
|
||||
}
|
||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||
setRemoving(true)
|
||||
fetch(`/api/users/${encodeURIComponent(user.id)}`, { method: 'DELETE' })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const data = await r.json()
|
||||
alert(data.error ?? 'Failed to delete user')
|
||||
setRemoving(false)
|
||||
setConfirming(false)
|
||||
} else {
|
||||
onChanged()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setRemoving(false)
|
||||
setConfirming(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||
setConfirming(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{user.username}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{
|
||||
backgroundColor: user.role === 'admin' ? 'var(--accent)' : 'var(--surface-raised, var(--border))',
|
||||
color: user.role === 'admin' ? 'var(--background)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{user.role === 'user' && (
|
||||
<button
|
||||
onClick={() => setShowPermissions((v) => !v)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{showPermissions ? 'Hide' : 'Libraries'}
|
||||
</button>
|
||||
)}
|
||||
{confirming ? (
|
||||
<>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Sure?
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
disabled={removing}
|
||||
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
|
||||
style={{ color: '#ef4444' }}
|
||||
>
|
||||
{removing ? 'Deleting…' : 'Yes, Delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color = '#ef4444'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPermissions && user.role === 'user' && (
|
||||
<PermissionsPanel userId={user.id} libraries={libraries} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Permissions Panel ────────────────────────────────────────────────────────
|
||||
|
||||
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
|
||||
const [permitted, setPermitted] = useState<string[]>([])
|
||||
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)
|
||||
setLoaded(true)
|
||||
})
|
||||
}, [userId])
|
||||
|
||||
const toggle = (libraryId: string) => {
|
||||
setPermitted((prev) =>
|
||||
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId]
|
||||
)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryIds: permitted }),
|
||||
})
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="pb-3 pl-2">
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-3 ml-2 p-3 rounded-lg border"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--background)' }}
|
||||
>
|
||||
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Library Access
|
||||
</p>
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="mt-3 text-xs px-3 py-1.5 rounded-lg transition-opacity"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent)',
|
||||
color: 'var(--background)',
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Add User Form ────────────────────────────────────────────────────────────
|
||||
|
||||
function AddUserForm({ onAdded }: { onAdded: () => void }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [role, setRole] = useState<'user' | 'admin'>('user')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, role }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setError(data.error ?? 'Failed to create user')
|
||||
return
|
||||
}
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setRole('user')
|
||||
onAdded()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<option value="user">User — access only permitted libraries</option>
|
||||
<option value="admin">Admin — access all libraries and settings</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm" style={{ color: '#ef4444' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="text-sm px-4 py-2 rounded-lg font-medium transition-opacity"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent)',
|
||||
color: 'var(--background)',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Creating…' : 'Create User'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Loading skeleton ─────────────────────────────────────────────────────────
|
||||
|
||||
function LoadingRows() {
|
||||
return (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="py-3 flex items-center gap-3">
|
||||
<div
|
||||
className="h-4 rounded animate-pulse"
|
||||
style={{ width: '120px', backgroundColor: 'var(--border)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 rounded animate-pulse"
|
||||
style={{ width: '50px', backgroundColor: 'var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getLibraries } from '@/lib/libraries'
|
||||
import { getServerSession } from '@/lib/auth'
|
||||
import { getLibrariesForUser } from '@/lib/users'
|
||||
import LibraryCard from '@/components/LibraryCard'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function HomePage() {
|
||||
const libraries = getLibraries()
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
const libraries = getLibrariesForUser(session.userId, session.role)
|
||||
|
||||
if (libraries.length === 0) {
|
||||
redirect('/manage')
|
||||
if (session.role === 'admin') {
|
||||
redirect('/manage')
|
||||
}
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
No libraries available
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
An administrator needs to grant you access to libraries.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -20,7 +35,7 @@ export default function HomePage() {
|
||||
Libraries
|
||||
</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured
|
||||
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user