Merge pull request 'login-user-settings' (#7) from login-user-settings into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s

Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/7
This commit is contained in:
2026-04-05 22:17:53 +00:00
50 changed files with 1766 additions and 35 deletions

1
.session_secret Normal file
View File

@@ -0,0 +1 @@
e63d0c98a24a2c5a25438b22b426d27e8ca9e3889e8d7a344b556ac48c5d51f3

40
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": {
"better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10",
"iron-session": "^8.0.4",
"next": "^15.5.14",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -2855,6 +2856,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4327,6 +4337,30 @@
"node": ">= 0.4"
}
},
"node_modules/iron-session": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz",
"integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==",
"funding": [
"https://github.com/sponsors/vvo",
"https://github.com/sponsors/brc-dd"
],
"license": "MIT",
"dependencies": {
"cookie": "^0.7.2",
"iron-webcrypto": "^1.2.1",
"uncrypto": "^0.1.3"
}
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
"integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/brc-dd"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -6953,6 +6987,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",

View File

@@ -14,6 +14,7 @@
"dependencies": {
"better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10",
"iron-session": "^8.0.4",
"next": "^15.5.14",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View 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
}

View 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
}

View 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 })
}
}

View File

@@ -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 })

View File

@@ -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 })

View File

@@ -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')

View File

@@ -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 })

View File

@@ -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)

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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')

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getUserSettings, updateUserSettings } from '@/lib/settings'
import type { UserSettings } from '@/types'
export async function GET(req: NextRequest) {
const auth = await requireAuth(req)
if (auth instanceof NextResponse) return auth
const settings = getUserSettings(auth.session.userId)
return NextResponse.json(settings)
}
export async function PUT(req: NextRequest) {
const auth = await requireAuth(req)
if (auth instanceof NextResponse) return auth
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const s = body as Record<string, unknown>
const boolFields: (keyof UserSettings)[] = [
'mixedAutoplay', 'mixedLoop', 'mixedMuted',
'moviesAutoplay', 'moviesLoop', 'moviesMuted',
'tvAutoplay', 'tvLoop', 'tvMuted',
]
for (const field of boolFields) {
if (typeof s[field] !== 'boolean') {
return NextResponse.json({ error: `Invalid value for ${field}` }, { status: 400 })
}
}
updateUserSettings(auth.session.userId, s as unknown as UserSettings)
return NextResponse.json({ ok: true })
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))
}

View File

@@ -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 })

View File

@@ -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')

View 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 })
}

View 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 })
}

View 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())
}

View File

@@ -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>

View File

@@ -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
View 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
View 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
View 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>
)
}

View File

@@ -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 />

View 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>
)
}

View File

@@ -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>

View File

@@ -0,0 +1,136 @@
'use client'
import { useState } from 'react'
import type { UserSettings } from '@/types'
interface Props {
initialSettings: UserSettings
}
interface ToggleProps {
label: string
checked: boolean
onChange: (v: boolean) => void
}
function Toggle({ label, checked, onChange }: ToggleProps) {
return (
<label className="flex items-center justify-between py-2 cursor-pointer select-none">
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
{label}
</span>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="relative w-10 h-6 rounded-full transition-colors flex-shrink-0"
style={{
backgroundColor: checked ? 'var(--accent)' : 'var(--border)',
}}
>
<span
className="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
style={{ transform: checked ? 'translateX(16px)' : 'translateX(0)' }}
/>
</button>
</label>
)
}
interface SectionProps {
title: string
icon: string
children: React.ReactNode
}
function SettingsSection({ title, icon, children }: SectionProps) {
return (
<div
className="rounded-xl p-5 mb-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<h2 className="text-sm font-semibold uppercase tracking-wider mb-3 flex items-center gap-2"
style={{ color: 'var(--text-secondary)' }}>
<span>{icon}</span>
{title}
</h2>
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{children}
</div>
</div>
)
}
export default function SettingsForm({ initialSettings }: Props) {
const [settings, setSettings] = useState<UserSettings>(initialSettings)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
function set<K extends keyof UserSettings>(key: K, value: boolean) {
setSettings((prev) => ({ ...prev, [key]: value }))
setSaved(false)
}
async function handleSave() {
setSaving(true)
setError(null)
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
if (!res.ok) throw new Error('Failed to save')
setSaved(true)
} catch {
setError('Failed to save settings. Please try again.')
} finally {
setSaving(false)
}
}
return (
<div className="max-w-md">
<SettingsSection title="Mixed Library" icon="🗂️">
<Toggle label="Autoplay" checked={settings.mixedAutoplay} onChange={(v) => set('mixedAutoplay', v)} />
<Toggle label="Loop" checked={settings.mixedLoop} onChange={(v) => set('mixedLoop', v)} />
<Toggle label="Start muted" checked={settings.mixedMuted} onChange={(v) => set('mixedMuted', v)} />
</SettingsSection>
<SettingsSection title="Movies" icon="🎬">
<Toggle label="Autoplay" checked={settings.moviesAutoplay} onChange={(v) => set('moviesAutoplay', v)} />
<Toggle label="Loop" checked={settings.moviesLoop} onChange={(v) => set('moviesLoop', v)} />
<Toggle label="Start muted" checked={settings.moviesMuted} onChange={(v) => set('moviesMuted', v)} />
</SettingsSection>
<SettingsSection title="TV Shows" icon="📺">
<Toggle label="Autoplay" checked={settings.tvAutoplay} onChange={(v) => set('tvAutoplay', v)} />
<Toggle label="Loop" checked={settings.tvLoop} onChange={(v) => set('tvLoop', v)} />
<Toggle label="Start muted" checked={settings.tvMuted} onChange={(v) => set('tvMuted', v)} />
</SettingsSection>
<div className="flex items-center gap-3 mt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-opacity"
style={{ backgroundColor: 'var(--accent)', color: '#fff', opacity: saving ? 0.6 : 1 }}
>
{saving ? 'Saving…' : 'Save Settings'}
</button>
{saved && (
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Saved
</span>
)}
{error && (
<span className="text-sm" style={{ color: '#ef4444' }}>
{error}
</span>
)}
</div>
</div>
)
}

27
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { getUserSettings } from '@/lib/settings'
import SettingsForm from './SettingsForm'
export default async function SettingsPage() {
const session = await getServerSession()
if (!session.userId) redirect('/login')
const settings = getUserSettings(session.userId)
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Settings
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Signed in as <strong>{session.username}</strong>
</p>
<h2 className="text-base font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Video Playback
</h2>
<SettingsForm initialSettings={settings} />
</div>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import NavLink from './NavLink'
interface Props {
username: string
isAdmin: boolean
}
export default function HeaderNav({ username, isAdmin }: Props) {
const router = useRouter()
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/login')
router.refresh()
}
return (
<div className="flex items-center gap-3">
{isAdmin && <NavLink href="/manage">Manage</NavLink>}
<Link
href="/settings"
className="text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
{username}
</Link>
<button
onClick={handleLogout}
className="text-sm px-3 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
Sign Out
</button>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'
const TABS = [
{ href: '/manage', label: 'Libraries' },
{ href: '/manage/tags', label: 'Tags' },
{ href: '/manage/users', label: 'Users' },
]
export default function ManageSubNav() {

View File

@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector'
import { useUserSettings } from '@/hooks/useUserSettings'
interface Props {
url: string
@@ -9,9 +10,14 @@ interface Props {
onClose: () => void
mediaKey?: string
onTagsChanged?: () => void
context?: 'mixed' | 'movies' | 'tv'
}
export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsChanged }: Props) {
export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsChanged, context = 'mixed' }: 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
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280
@@ -86,9 +92,9 @@ export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsC
<video
src={url}
controls
autoPlay
muted
loop
autoPlay={autoPlay}
muted={muted}
loop={loop}
className="w-full h-full object-contain rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
@@ -111,9 +117,9 @@ export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsC
<video
src={url}
controls
autoPlay
muted
loop
autoPlay={autoPlay}
muted={muted}
loop={loop}
className="w-full h-full max-w-4xl object-contain rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}

View File

@@ -72,6 +72,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
mediaKey={`${libraryId}:${movie.id}`}
onTagsChanged={onTagsChanged}
onClose={() => setPlaying(false)}
context="movies"
/>
)
}

View File

@@ -142,6 +142,7 @@ export default function TvView({ libraryId }: Props) {
url={videoUrl}
name={playingEpisode.title}
onClose={() => setPlayingEpisode(null)}
context="tv"
/>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { useEffect, useState } from 'react'
import type { UserSettings } from '@/types'
const DEFAULTS: UserSettings = {
mixedAutoplay: true,
mixedLoop: true,
mixedMuted: true,
moviesAutoplay: true,
moviesLoop: false,
moviesMuted: false,
tvAutoplay: true,
tvLoop: false,
tvMuted: false,
}
export function useUserSettings(): UserSettings {
const [settings, setSettings] = useState<UserSettings>(DEFAULTS)
useEffect(() => {
fetch('/api/settings')
.then((r) => (r.ok ? r.json() : null))
.then((data: UserSettings | null) => {
if (data) setSettings(data)
})
.catch(() => {/* fall back to defaults */})
}, [])
return settings
}

6
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeSecret } = await import('./lib/secret')
initializeSecret()
}
}

112
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,112 @@
import { getIronSession, type IronSession, type SessionOptions } from 'iron-session'
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import crypto from 'crypto'
export interface SessionData {
userId: string
username: string
role: 'admin' | 'user'
}
export function getSessionOptions(): SessionOptions {
const secret = process.env.SESSION_SECRET
if (!secret) throw new Error('SESSION_SECRET is not set')
return {
password: secret,
cookieName: 'ml_session',
ttl: 60 * 60 * 24 * 30, // 30 days
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
},
}
}
// For use in server components and layouts (Node.js runtime, read-only)
export async function getServerSession(): Promise<IronSession<SessionData>> {
const cookieStore = await cookies()
return getIronSession<SessionData>(cookieStore, getSessionOptions())
}
// For use in API routes where the session must be written (login/logout)
// The caller is responsible for returning the response so Set-Cookie headers are sent
export async function getWritableSession(
req: NextRequest,
res: NextResponse
): Promise<IronSession<SessionData>> {
return getIronSession<SessionData>(req, res, getSessionOptions())
}
// Password hashing using Node.js built-in crypto (Node.js runtime only)
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16)
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => {
if (err) reject(err)
else resolve(key)
})
})
return `scrypt:${salt.toString('hex')}:${derivedKey.toString('hex')}`
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
const parts = hash.split(':')
if (parts.length !== 3 || parts[0] !== 'scrypt') return false
const salt = Buffer.from(parts[1], 'hex')
const storedKey = Buffer.from(parts[2], 'hex')
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => {
if (err) reject(err)
else resolve(key)
})
})
if (derivedKey.length !== storedKey.length) return false
return crypto.timingSafeEqual(derivedKey, storedKey)
}
// Auth guard result type
type AuthSuccess = { session: IronSession<SessionData> }
type AuthResult = AuthSuccess | NextResponse
// Read-only session from an API route request (throwaway response)
async function getReadonlySession(req: NextRequest): Promise<IronSession<SessionData>> {
const res = new NextResponse()
return getIronSession<SessionData>(req, res, getSessionOptions())
}
export async function requireAuth(req: NextRequest): Promise<AuthResult> {
const session = await getReadonlySession(req)
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return { session }
}
export async function requireAdmin(req: NextRequest): Promise<AuthResult> {
const session = await getReadonlySession(req)
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return { session }
}
export async function requireLibraryAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
const session = await getReadonlySession(req)
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (session.role === 'admin') return { session }
// Lazy import to avoid pulling DB into edge contexts
const { getPermittedLibraryIds } = await import('./users')
const permitted = getPermittedLibraryIds(session.userId)
if (!permitted.includes(libraryId)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return { session }
}

View File

@@ -44,6 +44,33 @@ function initDb(db: Database.Database): void {
type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')),
cover_ext TEXT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS library_permissions (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, library_id)
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
mixed_autoplay INTEGER NOT NULL DEFAULT 1,
mixed_loop INTEGER NOT NULL DEFAULT 1,
mixed_muted INTEGER NOT NULL DEFAULT 1,
movies_autoplay INTEGER NOT NULL DEFAULT 1,
movies_loop INTEGER NOT NULL DEFAULT 0,
movies_muted INTEGER NOT NULL DEFAULT 0,
tv_autoplay INTEGER NOT NULL DEFAULT 1,
tv_loop INTEGER NOT NULL DEFAULT 0,
tv_muted INTEGER NOT NULL DEFAULT 0
);
`)
migrateLibrariesType(db)

25
src/lib/secret.ts Normal file
View File

@@ -0,0 +1,25 @@
import path from 'path'
import fs from 'fs'
import crypto from 'crypto'
const CONFIG_PATH = process.env.CONFIG_PATH ?? process.cwd()
const SECRET_FILE = path.resolve(CONFIG_PATH, '.session_secret')
export function initializeSecret(): void {
if (process.env.SESSION_SECRET) return
if (fs.existsSync(SECRET_FILE)) {
process.env.SESSION_SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim()
return
}
const secret = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 })
process.env.SESSION_SECRET = secret
}
export function getSessionSecret(): string {
const secret = process.env.SESSION_SECRET
if (!secret) throw new Error('SESSION_SECRET is not set — call initializeSecret() at startup')
return secret
}

81
src/lib/settings.ts Normal file
View File

@@ -0,0 +1,81 @@
import { getDb } from './db'
import type { UserSettings } from '@/types'
const DEFAULTS: UserSettings = {
mixedAutoplay: true,
mixedLoop: true,
mixedMuted: true,
moviesAutoplay: true,
moviesLoop: false,
moviesMuted: false,
tvAutoplay: true,
tvLoop: false,
tvMuted: false,
}
interface SettingsRow {
mixed_autoplay: number
mixed_loop: number
mixed_muted: number
movies_autoplay: number
movies_loop: number
movies_muted: number
tv_autoplay: number
tv_loop: number
tv_muted: number
}
function rowToSettings(row: SettingsRow): UserSettings {
return {
mixedAutoplay: row.mixed_autoplay === 1,
mixedLoop: row.mixed_loop === 1,
mixedMuted: row.mixed_muted === 1,
moviesAutoplay: row.movies_autoplay === 1,
moviesLoop: row.movies_loop === 1,
moviesMuted: row.movies_muted === 1,
tvAutoplay: row.tv_autoplay === 1,
tvLoop: row.tv_loop === 1,
tvMuted: row.tv_muted === 1,
}
}
export function getUserSettings(userId: string): UserSettings {
const db = getDb()
const row = db
.prepare('SELECT * FROM user_settings WHERE user_id = ?')
.get(userId) as SettingsRow | undefined
return row ? rowToSettings(row) : { ...DEFAULTS }
}
export function updateUserSettings(userId: string, settings: UserSettings): void {
const db = getDb()
db.prepare(`
INSERT INTO user_settings (
user_id,
mixed_autoplay, mixed_loop, mixed_muted,
movies_autoplay, movies_loop, movies_muted,
tv_autoplay, tv_loop, tv_muted
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
mixed_autoplay = excluded.mixed_autoplay,
mixed_loop = excluded.mixed_loop,
mixed_muted = excluded.mixed_muted,
movies_autoplay = excluded.movies_autoplay,
movies_loop = excluded.movies_loop,
movies_muted = excluded.movies_muted,
tv_autoplay = excluded.tv_autoplay,
tv_loop = excluded.tv_loop,
tv_muted = excluded.tv_muted
`).run(
userId,
settings.mixedAutoplay ? 1 : 0,
settings.mixedLoop ? 1 : 0,
settings.mixedMuted ? 1 : 0,
settings.moviesAutoplay ? 1 : 0,
settings.moviesLoop ? 1 : 0,
settings.moviesMuted ? 1 : 0,
settings.tvAutoplay ? 1 : 0,
settings.tvLoop ? 1 : 0,
settings.tvMuted ? 1 : 0,
)
}

119
src/lib/users.ts Normal file
View File

@@ -0,0 +1,119 @@
import { getDb } from './db'
import { getLibraries } from './libraries'
import type { Library } from '@/types'
export interface User {
id: string
username: string
role: 'admin' | 'user'
createdAt: number
}
export interface UserWithHash extends User {
passwordHash: string
}
export function getUserCount(): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
return row.count
}
export function getUserByUsername(username: string): UserWithHash | undefined {
const db = getDb()
const row = db
.prepare('SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?')
.get(username) as { id: string; username: string; password_hash: string; role: string; created_at: number } | undefined
if (!row) return undefined
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
role: row.role as 'admin' | 'user',
createdAt: row.created_at,
}
}
export function getUserById(id: string): User | undefined {
const db = getDb()
const row = db
.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?')
.get(id) as { id: string; username: string; role: string; created_at: number } | undefined
if (!row) return undefined
return {
id: row.id,
username: row.username,
role: row.role as 'admin' | 'user',
createdAt: row.created_at,
}
}
export function createUser(username: string, passwordHash: string, role: 'admin' | 'user'): User {
const db = getDb()
const id = crypto.randomUUID()
const now = Math.floor(Date.now() / 1000)
db.prepare(
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
).run(id, username, passwordHash, role, now)
return { id, username, role, createdAt: now }
}
export function deleteUser(id: string): boolean {
const db = getDb()
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id)
return result.changes > 0
}
export function listUsers(): User[] {
const db = getDb()
const rows = db
.prepare('SELECT id, username, role, created_at FROM users ORDER BY created_at ASC')
.all() as { id: string; username: string; role: string; created_at: number }[]
return rows.map((r) => ({
id: r.id,
username: r.username,
role: r.role as 'admin' | 'user',
createdAt: r.created_at,
}))
}
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 function setLibraryPermissions(userId: string, libraryIds: string[]): 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)
}
})
tx()
}
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
if (role === 'admin') return getLibraries()
const db = getDb()
const rows = db
.prepare(
`SELECT l.id, l.name, l.path, l.type, l.cover_ext
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 }[]
return rows.map((r) => ({
id: r.id,
name: r.name,
path: r.path,
type: r.type as Library['type'],
coverExt: r.cover_ext,
}))
}

28
src/middleware.ts Normal file
View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { getIronSession } from 'iron-session'
import { getSessionOptions, type SessionData } from '@/lib/auth'
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname === '/login' || pathname.startsWith('/api/auth/')) {
return NextResponse.next()
}
const response = NextResponse.next()
const session = await getIronSession<SessionData>(request, response, getSessionOptions())
if (!session.userId) {
const loginUrl = new URL('/login', request.url)
if (pathname !== '/') {
loginUrl.searchParams.set('from', pathname)
}
return NextResponse.redirect(loginUrl)
}
return response
}

View File

@@ -95,3 +95,15 @@ export interface Tag {
name: string
categoryId: string
}
export interface UserSettings {
mixedAutoplay: boolean
mixedLoop: boolean
mixedMuted: boolean
moviesAutoplay: boolean
moviesLoop: boolean
moviesMuted: boolean
tvAutoplay: boolean
tvLoop: boolean
tvMuted: boolean
}