login-user-settings #7
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": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"fast-xml-parser": "^5.5.10",
|
"fast-xml-parser": "^5.5.10",
|
||||||
|
"iron-session": "^8.0.4",
|
||||||
"next": "^15.5.14",
|
"next": "^15.5.14",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -2855,6 +2856,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4327,6 +4337,30 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"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"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"fast-xml-parser": "^5.5.10",
|
"fast-xml-parser": "^5.5.10",
|
||||||
|
"iron-session": "^8.0.4",
|
||||||
"next": "^15.5.14",
|
"next": "^15.5.14",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^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 { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
||||||
import { scanDirectory } from '@/lib/files'
|
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 { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
const subpath = searchParams.get('path') ?? ''
|
const subpath = searchParams.get('path') ?? ''
|
||||||
@@ -11,6 +12,9 @@ export function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const library = getLibrary(libraryId)
|
const library = getLibrary(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
'.mp4': 'video/mp4',
|
'.mp4': 'video/mp4',
|
||||||
@@ -35,6 +36,9 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
|
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)
|
const library = getLibrary(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from 'fs'
|
|||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
const itemId = searchParams.get('itemId')
|
const itemId = searchParams.get('itemId')
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
||||||
import { scanGamesLibrary } from '@/lib/games'
|
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 { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
|
||||||
@@ -10,6 +11,9 @@ export function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const library = getLibrary(libraryId)
|
const library = getLibrary(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, removeLibrary } from '@/lib/libraries'
|
import { getLibrary, removeLibrary } from '@/lib/libraries'
|
||||||
import { removeAllAssignmentsForLibrary } from '@/lib/tags'
|
import { removeAllAssignmentsForLibrary } from '@/lib/tags'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const library = getLibrary(id)
|
const library = getLibrary(id)
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibraries, addLibrary } from '@/lib/libraries'
|
import { getLibraries, addLibrary } from '@/lib/libraries'
|
||||||
|
import { getLibrariesForUser } from '@/lib/users'
|
||||||
|
import { requireAuth, requireAdmin } from '@/lib/auth'
|
||||||
import type { LibraryType } from '@/types'
|
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 {
|
try {
|
||||||
const libraries = getLibraries()
|
const libraries =
|
||||||
|
session.role === 'admin'
|
||||||
|
? getLibraries()
|
||||||
|
: getLibrariesForUser(session.userId, session.role)
|
||||||
return NextResponse.json(libraries)
|
return NextResponse.json(libraries)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to read libraries', err)
|
console.error('Failed to read libraries', err)
|
||||||
@@ -13,6 +22,9 @@ export function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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 }
|
let body: { name?: string; path?: string; type?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from 'fs'
|
|||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, updateLibraryCover, clearLibraryCover } from '@/lib/libraries'
|
import { getLibrary, updateLibraryCover, clearLibraryCover } from '@/lib/libraries'
|
||||||
|
import { requireAuth, requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
const COVERS_DIR = path.resolve(process.cwd(), '.covers')
|
const COVERS_DIR = path.resolve(process.cwd(), '.covers')
|
||||||
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
|
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
|
||||||
@@ -12,9 +13,12 @@ function coverPath(id: string, ext: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const library = getLibrary(id)
|
const library = getLibrary(id)
|
||||||
if (!library?.coverExt) {
|
if (!library?.coverExt) {
|
||||||
@@ -39,6 +43,9 @@ export async function POST(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const library = getLibrary(id)
|
const library = getLibrary(id)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
@@ -85,9 +92,12 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const library = getLibrary(id)
|
const library = getLibrary(id)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { scanMoviesLibrary } from '@/lib/movies'
|
import { scanMoviesLibrary } from '@/lib/movies'
|
||||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
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 { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ export function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const library = getLibrary(libraryId)
|
const library = getLibrary(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
@@ -26,7 +30,10 @@ export function GET(request: NextRequest) {
|
|||||||
return NextResponse.json(movies)
|
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 { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
const movieId = searchParams.get('movieId')
|
const movieId = searchParams.get('movieId')
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -8,6 +15,13 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!mediaKey) {
|
if (!mediaKey) {
|
||||||
return NextResponse.json({ error: 'mediaKey is required' }, { status: 400 })
|
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))
|
return NextResponse.json(getResolvedTagsForItem(mediaKey))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||||
@@ -20,6 +34,13 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!mediaKey || !tagId) {
|
if (!mediaKey || !tagId) {
|
||||||
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 })
|
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)
|
addTagToItem(mediaKey, tagId)
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -35,6 +56,13 @@ export async function DELETE(request: NextRequest) {
|
|||||||
if (!mediaKey || !tagId) {
|
if (!mediaKey || !tagId) {
|
||||||
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 })
|
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)
|
removeTagFromItem(mediaKey, tagId)
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags'
|
import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { name } = await request.json()
|
const { name } = await request.json()
|
||||||
@@ -19,6 +23,9 @@ export async function DELETE(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getCategories, addCategory } from '@/lib/tags'
|
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 {
|
try {
|
||||||
return NextResponse.json(getCategories())
|
return NextResponse.json(getCategories())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -10,6 +14,9 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name } = await request.json()
|
const { name } = await request.json()
|
||||||
const category = addCategory(name)
|
const category = addCategory(name)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { updateTag, deleteTag } from '@/lib/tags'
|
import { updateTag, deleteTag } from '@/lib/tags'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { name } = await request.json()
|
const { name } = await request.json()
|
||||||
@@ -16,9 +20,12 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
deleteTag(id)
|
deleteTag(id)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getTags, getTagsSortedByUsage, addTag } from '@/lib/tags'
|
import { getTags, getTagsSortedByUsage, addTag } from '@/lib/tags'
|
||||||
|
import { requireAuth, requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const categoryId = searchParams.get('categoryId') ?? undefined
|
const categoryId = searchParams.get('categoryId') ?? undefined
|
||||||
@@ -15,6 +19,9 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, categoryId } = await request.json()
|
const { name, categoryId } = await request.json()
|
||||||
const tag = addTag(name, categoryId)
|
const tag = addTag(name, categoryId)
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getTagAssignmentsForLibrary } from '@/lib/tags'
|
import { getTagAssignmentsForLibrary } from '@/lib/tags'
|
||||||
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
if (!libraryId) return Response.json({ error: 'libraryId required' }, { status: 400 })
|
if (!libraryId) return NextResponse.json({ error: 'libraryId required' }, { status: 400 })
|
||||||
return Response.json(getTagAssignmentsForLibrary(libraryId))
|
|
||||||
|
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 path from 'path'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { getThumbnailPath } from '@/lib/thumbnails'
|
import { getThumbnailPath } from '@/lib/thumbnails'
|
||||||
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
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 })
|
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)
|
const library = getLibrary(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
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 { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv'
|
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv'
|
||||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
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 { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
const seriesId = searchParams.get('seriesId')
|
const seriesId = searchParams.get('seriesId')
|
||||||
@@ -15,6 +16,9 @@ export function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const library = getLibrary(libraryId)
|
const library = getLibrary(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
@@ -39,7 +43,10 @@ export function GET(request: NextRequest) {
|
|||||||
return NextResponse.json(series)
|
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 { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
const seriesId = searchParams.get('seriesId')
|
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 type { Metadata } from 'next'
|
||||||
import NavLink from '@/components/NavLink'
|
import { getServerSession } from '@/lib/auth'
|
||||||
|
import HeaderNav from '@/components/HeaderNav'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -7,11 +8,13 @@ export const metadata: Metadata = {
|
|||||||
description: 'Your personal media library',
|
description: 'Your personal media library',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const session = await getServerSession()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="min-h-screen">
|
<body className="min-h-screen">
|
||||||
@@ -22,7 +25,9 @@ export default function RootLayout({
|
|||||||
MediaLore
|
MediaLore
|
||||||
</a>
|
</a>
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink href="/manage">Manage</NavLink>
|
{session.userId && (
|
||||||
|
<HeaderNav username={session.username} isAdmin={session.role === 'admin'} />
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { getLibrary } from '@/lib/libraries'
|
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 GamesView from '@/components/games/GamesView'
|
||||||
import MixedView from '@/components/mixed/MixedView'
|
import MixedView from '@/components/mixed/MixedView'
|
||||||
import MoviesView from '@/components/movies/MoviesView'
|
import MoviesView from '@/components/movies/MoviesView'
|
||||||
@@ -14,9 +16,17 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
|||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { path: subpath } = await searchParams
|
const { path: subpath } = await searchParams
|
||||||
|
|
||||||
|
const session = await getServerSession()
|
||||||
|
if (!session.userId) redirect('/login')
|
||||||
|
|
||||||
const library = getLibrary(id)
|
const library = getLibrary(id)
|
||||||
if (!library) notFound()
|
if (!library) notFound()
|
||||||
|
|
||||||
|
if (session.role !== 'admin') {
|
||||||
|
const permitted = getPermittedLibraryIds(session.userId)
|
||||||
|
if (!permitted.includes(id)) notFound()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<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'
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ManageSubNav />
|
<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,16 +1,31 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { redirect } from 'next/navigation'
|
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 LibraryCard from '@/components/LibraryCard'
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default async function HomePage() {
|
||||||
const libraries = getLibraries()
|
const session = await getServerSession()
|
||||||
|
if (!session.userId) redirect('/login')
|
||||||
|
|
||||||
|
const libraries = getLibrariesForUser(session.userId, session.role)
|
||||||
|
|
||||||
if (libraries.length === 0) {
|
if (libraries.length === 0) {
|
||||||
|
if (session.role === 'admin') {
|
||||||
redirect('/manage')
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -20,7 +35,7 @@ export default function HomePage() {
|
|||||||
Libraries
|
Libraries
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 = [
|
const TABS = [
|
||||||
{ href: '/manage', label: 'Libraries' },
|
{ href: '/manage', label: 'Libraries' },
|
||||||
{ href: '/manage/tags', label: 'Tags' },
|
{ href: '/manage/tags', label: 'Tags' },
|
||||||
|
{ href: '/manage/users', label: 'Users' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function ManageSubNav() {
|
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')),
|
type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')),
|
||||||
cover_ext TEXT NULL
|
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)
|
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