add auth
This commit is contained in:
1
.session_secret
Normal file
1
.session_secret
Normal file
@@ -0,0 +1 @@
|
||||
e63d0c98a24a2c5a25438b22b426d27e8ca9e3889e8d7a344b556ac48c5d51f3
|
||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
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>
|
||||
|
||||
43
src/components/HeaderNav.tsx
Normal file
43
src/components/HeaderNav.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
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>}
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{username}
|
||||
</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
6
src/instrumentation.ts
Normal file
6
src/instrumentation.ts
Normal 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
112
src/lib/auth.ts
Normal 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 }
|
||||
}
|
||||
@@ -44,6 +44,20 @@ 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)
|
||||
);
|
||||
`)
|
||||
|
||||
migrateLibrariesType(db)
|
||||
|
||||
25
src/lib/secret.ts
Normal file
25
src/lib/secret.ts
Normal 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
|
||||
}
|
||||
119
src/lib/users.ts
Normal file
119
src/lib/users.ts
Normal 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
28
src/middleware.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user