From 61b183542cd5d9e268663cbeda50e6cc0aea35ae Mon Sep 17 00:00:00 2001 From: Michael Borohovski Date: Tue, 3 Mar 2026 12:23:57 -0800 Subject: [PATCH 01/15] Add per-user API tokens with admin override support - Add userId field to ApiToken schema (the user identity the token acts as) - Auth middleware resolves token identity via userId instead of createdById - New /api/user/api-tokens routes for self-service token management - Admin /api/admin/api-tokens routes support userId and role overrides - API Tokens section on profile page for all users - Admin API tab shows all tokens with user/role selectors --- prisma/schema.prisma | 30 ++ src/app/admin/settings/lib/helpers.ts | 2 + src/app/admin/settings/lib/types.ts | 2 +- src/app/admin/settings/page.tsx | 6 +- src/app/admin/settings/tabs/ApiTab/ApiTab.tsx | 414 ++++++++++++++++++ src/app/api/admin/api-tokens/[id]/route.ts | 42 ++ src/app/api/admin/api-tokens/route.ts | 145 ++++++ src/app/api/user/api-tokens/[id]/route.ts | 45 ++ src/app/api/user/api-tokens/route.ts | 114 +++++ src/app/profile/page.tsx | 4 + src/components/profile/ApiTokensSection.tsx | 328 ++++++++++++++ src/lib/middleware/auth.ts | 63 ++- 12 files changed, 1192 insertions(+), 3 deletions(-) create mode 100644 src/app/admin/settings/tabs/ApiTab/ApiTab.tsx create mode 100644 src/app/api/admin/api-tokens/[id]/route.ts create mode 100644 src/app/api/admin/api-tokens/route.ts create mode 100644 src/app/api/user/api-tokens/[id]/route.ts create mode 100644 src/app/api/user/api-tokens/route.ts create mode 100644 src/components/profile/ApiTokensSection.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a39100..6e4f251 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,8 @@ model User { goodreadsShelves GoodreadsShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") + createdApiTokens ApiToken[] @relation("CreatedApiTokens") + apiTokens ApiToken[] @relation("UserApiTokens") @@index([plexId]) @@index([role]) @@ -496,6 +498,34 @@ model ReportedIssue { // Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache // ============================================================================ +// ============================================================================ +// API TOKEN TABLE +// Static API tokens for programmatic access (alternative to JWT) +// Documentation: documentation/backend/services/api-tokens.md +// ============================================================================ + +model ApiToken { + id String @id @default(uuid()) + name String // User-friendly label (e.g., "Home Assistant", "Webhook") + tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext) + tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2") + role String @default("user") // Token role: 'admin' or 'user' + createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens) + userId String @map("user_id") // The user identity this token acts as + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") // null = never expires + createdAt DateTime @default(now()) @map("created_at") + + // Relations + createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade) + tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade) + + @@index([tokenHash]) + @@index([createdById]) + @@index([userId]) + @@map("api_tokens") +} + model GoodreadsShelf { id String @id @default(uuid()) userId String @map("user_id") diff --git a/src/app/admin/settings/lib/helpers.ts b/src/app/admin/settings/lib/helpers.ts index ff8f648..38271c5 100644 --- a/src/app/admin/settings/lib/helpers.ts +++ b/src/app/admin/settings/lib/helpers.ts @@ -210,6 +210,7 @@ export const getTabValidation = ( return validated.paths; case 'ebook': case 'bookdate': + case 'api': return true; // These tabs handle their own saving default: return false; @@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [ { id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' }, { id: 'bookdate' as const, label: 'BookDate', icon: '📚' }, { id: 'notifications' as const, label: 'Notifications', icon: '🔔' }, + { id: 'api' as const, label: 'API', icon: '🔑' }, ]; diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 6a104cf..bbb4070 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -243,4 +243,4 @@ export interface BookDateModel { /** * Tab identifier type */ -export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications'; +export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api'; diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 01b5416..13af2e1 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab'; import { EbookTab } from './tabs/EbookTab/EbookTab'; import { BookDateTab } from './tabs/BookDateTab/BookDateTab'; import { NotificationsTab } from './tabs/NotificationsTab'; +import { ApiTab } from './tabs/ApiTab/ApiTab'; // Types and Helpers import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types'; @@ -346,8 +347,11 @@ export default function AdminSettings() { {/* Notifications Tab */} {activeTab === 'notifications' && } + {/* API Tab */} + {activeTab === 'api' && } + {/* Save Button (only for tabs that save through main page) */} - {activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && ( + {activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
+
+ + + + + )} + + {/* Create token form */} + {showCreateForm ? ( +
+

Create New Token

+
+
+ + setNewTokenName(e.target.value)} + placeholder="e.g., Home Assistant, Webhook" + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ) : ( + + )} + + {/* Token list */} + {tokens.length === 0 ? ( +
+ + + +

No API tokens yet

+

Create a token to enable programmatic API access

+
+ ) : ( +
+ + + + + + + + + + + + + + + {tokens.map((token) => ( + + + + + + + + + + + ))} + +
NameTokenActs AsRoleCreated ByLast UsedExpiresActions
{token.name} + + {token.tokenPrefix}... + + {token.tokenUser} + + {token.role} + + {token.createdBy}{formatDate(token.lastUsedAt)} + {token.expiresAt ? ( + + {formatDate(token.expiresAt)} + {new Date(token.expiresAt) < new Date() && ' (expired)'} + + ) : ( + 'Never' + )} + + +
+
+ )} + + {/* Usage instructions */} +
+

Usage

+

+ Include the token in the Authorization header: +

+
+{`curl -H "Authorization: Bearer rmab_your_token_here" \\
+  ${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
+        
+
+ + ); +} diff --git a/src/app/api/admin/api-tokens/[id]/route.ts b/src/app/api/admin/api-tokens/[id]/route.ts new file mode 100644 index 0000000..cfe7b38 --- /dev/null +++ b/src/app/api/admin/api-tokens/[id]/route.ts @@ -0,0 +1,42 @@ +/** + * Component: API Token Delete Route + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Admin.ApiTokens'); + +/** + * DELETE /api/admin/api-tokens/[id] + * Revoke (delete) an API token + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, (req: AuthenticatedRequest) => + requireAdmin(req, async () => { + try { + const { id } = await params; + + const token = await prisma.apiToken.findUnique({ where: { id } }); + if (!token) { + return NextResponse.json({ error: 'Token not found' }, { status: 404 }); + } + + await prisma.apiToken.delete({ where: { id } }); + + logger.info('API token revoked', { tokenId: id, name: token.name, revokedBy: req.user!.username }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to revoke API token', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 }); + } + }) + ); +} diff --git a/src/app/api/admin/api-tokens/route.ts b/src/app/api/admin/api-tokens/route.ts new file mode 100644 index 0000000..34bf58f --- /dev/null +++ b/src/app/api/admin/api-tokens/route.ts @@ -0,0 +1,145 @@ +/** + * Component: Admin API Token Management Routes + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { z } from 'zod'; + +const logger = RMABLogger.create('API.Admin.ApiTokens'); + +const API_TOKEN_PREFIX = 'rmab_'; +const TOKEN_RANDOM_BYTES = 32; + +const CreateTokenSchema = z.object({ + name: z.string().min(1).max(100), + expiresAt: z.string().datetime().nullable().optional(), + userId: z.string().uuid().optional(), // Admin can specify which user the token acts as + role: z.enum(['admin', 'user']).optional(), // Admin can override the token role +}); + +/** + * GET /api/admin/api-tokens + * List ALL API tokens across all users + */ +export async function GET(request: NextRequest) { + return requireAuth(request, (req: AuthenticatedRequest) => + requireAdmin(req, async () => { + try { + const tokens = await prisma.apiToken.findMany({ + include: { + createdBy: { + select: { id: true, plexUsername: true }, + }, + tokenUser: { + select: { id: true, plexUsername: true, role: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const sanitized = tokens.map((t) => ({ + id: t.id, + name: t.name, + tokenPrefix: t.tokenPrefix, + role: t.role, + createdBy: t.createdBy.plexUsername, + createdById: t.createdBy.id, + tokenUser: t.tokenUser.plexUsername, + tokenUserId: t.tokenUser.id, + lastUsedAt: t.lastUsedAt, + expiresAt: t.expiresAt, + createdAt: t.createdAt, + })); + + return NextResponse.json({ tokens: sanitized }); + } catch (error) { + logger.error('Failed to list API tokens', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 }); + } + }) + ); +} + +/** + * POST /api/admin/api-tokens + * Create a new API token. Admin can optionally specify userId and role. + * Returns the full token ONCE. + */ +export async function POST(request: NextRequest) { + return requireAuth(request, (req: AuthenticatedRequest) => + requireAdmin(req, async () => { + try { + const body = await req.json(); + const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body); + + // Determine target user (defaults to the admin themselves) + const targetUserId = userId || req.user!.id; + + // Verify the target user exists + const targetUser = await prisma.user.findUnique({ + where: { id: targetUserId }, + select: { id: true, role: true, plexUsername: true }, + }); + + if (!targetUser) { + return NextResponse.json({ error: 'Target user not found' }, { status: 404 }); + } + + // Determine token role (defaults to target user's role) + const tokenRole = role || targetUser.role; + + // Generate the token + const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex'); + const fullToken = `${API_TOKEN_PREFIX}${randomPart}`; + const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex'); + const tokenPrefix = fullToken.substring(0, 12); // "rmab_" + 7 chars + + const apiToken = await prisma.apiToken.create({ + data: { + name, + tokenHash, + tokenPrefix, + role: tokenRole, + createdById: req.user!.id, + userId: targetUserId, + expiresAt: expiresAt ? new Date(expiresAt) : null, + }, + }); + + logger.info('Admin API token created', { + tokenId: apiToken.id, + name, + createdBy: req.user!.username, + targetUser: targetUser.plexUsername, + role: tokenRole, + }); + + return NextResponse.json({ + token: { + id: apiToken.id, + name: apiToken.name, + tokenPrefix: apiToken.tokenPrefix, + role: apiToken.role, + expiresAt: apiToken.expiresAt, + createdAt: apiToken.createdAt, + }, + // Full token is returned ONLY on creation + fullToken, + }, { status: 201 }); + } catch (error) { + logger.error('Failed to create API token', { error: error instanceof Error ? error.message : String(error) }); + + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 }); + } + }) + ); +} diff --git a/src/app/api/user/api-tokens/[id]/route.ts b/src/app/api/user/api-tokens/[id]/route.ts new file mode 100644 index 0000000..771113c --- /dev/null +++ b/src/app/api/user/api-tokens/[id]/route.ts @@ -0,0 +1,45 @@ +/** + * Component: User API Token Delete Route (self-service) + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.User.ApiTokens'); + +/** + * DELETE /api/user/api-tokens/[id] + * Revoke (delete) one of the current user's own API tokens + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const { id } = await params; + + const token = await prisma.apiToken.findUnique({ where: { id } }); + if (!token) { + return NextResponse.json({ error: 'Token not found' }, { status: 404 }); + } + + // Only allow deleting own tokens + if (token.userId !== req.user!.id) { + return NextResponse.json({ error: 'Token not found' }, { status: 404 }); + } + + await prisma.apiToken.delete({ where: { id } }); + + logger.info('User API token revoked', { tokenId: id, name: token.name, userId: req.user!.id }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to revoke user API token', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/api-tokens/route.ts b/src/app/api/user/api-tokens/route.ts new file mode 100644 index 0000000..8ffa8af --- /dev/null +++ b/src/app/api/user/api-tokens/route.ts @@ -0,0 +1,114 @@ +/** + * Component: User API Token Routes (self-service) + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { z } from 'zod'; + +const logger = RMABLogger.create('API.User.ApiTokens'); + +const API_TOKEN_PREFIX = 'rmab_'; +const TOKEN_RANDOM_BYTES = 32; + +const CreateTokenSchema = z.object({ + name: z.string().min(1).max(100), + expiresAt: z.string().datetime().nullable().optional(), +}); + +/** + * GET /api/user/api-tokens + * List the current user's own API tokens + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const tokens = await prisma.apiToken.findMany({ + where: { userId: req.user!.id }, + orderBy: { createdAt: 'desc' }, + }); + + const sanitized = tokens.map((t) => ({ + id: t.id, + name: t.name, + tokenPrefix: t.tokenPrefix, + role: t.role, + lastUsedAt: t.lastUsedAt, + expiresAt: t.expiresAt, + createdAt: t.createdAt, + })); + + return NextResponse.json({ tokens: sanitized }); + } catch (error) { + logger.error('Failed to list user API tokens', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 }); + } + }); +} + +/** + * POST /api/user/api-tokens + * Create a token for the current user with their own role. Returns full token ONCE. + */ +export async function POST(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const body = await req.json(); + const { name, expiresAt } = CreateTokenSchema.parse(body); + + // Look up the user's actual role from the database + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { role: true }, + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Generate the token + const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex'); + const fullToken = `${API_TOKEN_PREFIX}${randomPart}`; + const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex'); + const tokenPrefix = fullToken.substring(0, 12); // "rmab_" + 7 chars + + const apiToken = await prisma.apiToken.create({ + data: { + name, + tokenHash, + tokenPrefix, + role: user.role, // Always the user's own role + createdById: req.user!.id, + userId: req.user!.id, // Token acts as the current user + expiresAt: expiresAt ? new Date(expiresAt) : null, + }, + }); + + logger.info('User API token created', { tokenId: apiToken.id, name, userId: req.user!.id }); + + return NextResponse.json({ + token: { + id: apiToken.id, + name: apiToken.name, + tokenPrefix: apiToken.tokenPrefix, + role: apiToken.role, + expiresAt: apiToken.expiresAt, + createdAt: apiToken.createdAt, + }, + fullToken, + }, { status: 201 }); + } catch (error) { + logger.error('Failed to create user API token', { error: error instanceof Error ? error.message : String(error) }); + + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 }); + } + }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 1d88640..e6accff 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; +import { ApiTokensSection } from '@/components/profile/ApiTokensSection'; const statConfig = [ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, @@ -233,6 +234,9 @@ export default function ProfilePage() { )} + + {/* API Tokens */} + ); diff --git a/src/components/profile/ApiTokensSection.tsx b/src/components/profile/ApiTokensSection.tsx new file mode 100644 index 0000000..846da4c --- /dev/null +++ b/src/components/profile/ApiTokensSection.tsx @@ -0,0 +1,328 @@ +/** + * Component: API Tokens Section (Profile Page) + * Documentation: documentation/backend/services/api-tokens.md + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; + +interface ApiToken { + id: string; + name: string; + tokenPrefix: string; + role: string; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; +} + +export function ApiTokensSection() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [newTokenName, setNewTokenName] = useState(''); + const [newTokenExpiry, setNewTokenExpiry] = useState('never'); + const [showCreateForm, setShowCreateForm] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const fetchTokens = useCallback(async () => { + try { + const response = await fetchWithAuth('/api/user/api-tokens'); + if (response.ok) { + const data = await response.json(); + setTokens(data.tokens); + } + } catch { + setError('Failed to load API tokens'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchTokens(); + }, [fetchTokens]); + + const handleCreate = async () => { + if (!newTokenName.trim()) { + setError('Token name is required'); + return; + } + + setCreating(true); + setError(null); + + try { + let expiresAt: string | null = null; + if (newTokenExpiry !== 'never') { + const date = new Date(); + switch (newTokenExpiry) { + case '30d': date.setDate(date.getDate() + 30); break; + case '90d': date.setDate(date.getDate() + 90); break; + case '1y': date.setFullYear(date.getFullYear() + 1); break; + } + expiresAt = date.toISOString(); + } + + const response = await fetchWithAuth('/api/user/api-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newTokenName.trim(), expiresAt }), + }); + + if (response.ok) { + const data = await response.json(); + setCreatedToken(data.fullToken); + setNewTokenName(''); + setNewTokenExpiry('never'); + setShowCreateForm(false); + await fetchTokens(); + } else { + const data = await response.json(); + setError(data.error || 'Failed to create token'); + } + } catch { + setError('Failed to create token'); + } finally { + setCreating(false); + } + }; + + const handleDelete = async (id: string) => { + setDeletingId(id); + setError(null); + + try { + const response = await fetchWithAuth(`/api/user/api-tokens/${id}`, { + method: 'DELETE', + }); + + if (response.ok) { + setTokens(tokens.filter((t) => t.id !== id)); + } else { + setError('Failed to revoke token'); + } + } catch { + setError('Failed to revoke token'); + } finally { + setDeletingId(null); + } + }; + + const handleCopy = async () => { + if (createdToken) { + await navigator.clipboard.writeText(createdToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return 'Never'; + return new Date(dateStr).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( +
+
+
+

+ API Tokens +

+

+ Create personal API tokens for programmatic access to the API. +

+
+
+ +
+
+ {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {/* Newly created token banner */} + {createdToken && ( +
+
+ + + +
+

+ Token created successfully! Copy it now — it won't be shown again. +

+
+ + {createdToken} + + +
+
+ +
+
+ )} + + {/* Create token form */} + {showCreateForm ? ( +
+

Create New Token

+
+
+ + setNewTokenName(e.target.value)} + placeholder="e.g., Home Assistant, Webhook" + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +
+
+ + +
+
+
+ + +
+
+ ) : ( + + )} + + {/* Token list */} + {loading ? ( +
+
+
+ ) : tokens.length === 0 ? ( +
+ + + +

No API tokens yet

+

Create a token to enable programmatic API access

+
+ ) : ( +
+ + + + + + + + + + + + {tokens.map((token) => ( + + + + + + + + ))} + +
NameTokenLast UsedExpiresActions
{token.name} + + {token.tokenPrefix}... + + {formatDate(token.lastUsedAt)} + {token.expiresAt ? ( + + {formatDate(token.expiresAt)} + {new Date(token.expiresAt) < new Date() && ' (expired)'} + + ) : ( + 'Never' + )} + + +
+
+ )} + + {/* Usage instructions */} +
+

Usage

+

+ Include the token in the Authorization header: +

+
+{`curl -H "Authorization: Bearer rmab_your_token_here" \\
+  ${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
+            
+
+
+
+
+ ); +} diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index ce44d33..b42940d 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -4,12 +4,15 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; import { verifyAccessToken, TokenPayload } from '../utils/jwt'; import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; const logger = RMABLogger.create('Auth'); +const API_TOKEN_PREFIX = 'rmab_'; + export interface AuthenticatedRequest extends NextRequest { user?: TokenPayload & { id: string }; } @@ -32,9 +35,47 @@ function extractToken(request: NextRequest): string | null { return parts[1]; } +/** + * Authenticate via static API token (rmab_ prefix). + * Returns a synthetic TokenPayload if valid, null otherwise. + * Updates lastUsedAt asynchronously. + */ +async function authenticateApiToken(token: string): Promise<(TokenPayload & { id: string }) | null> { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + const apiToken = await prisma.apiToken.findUnique({ + where: { tokenHash }, + include: { tokenUser: { select: { id: true, plexId: true, plexUsername: true, role: true } } }, + }); + + if (!apiToken) return null; + + // Check expiration + if (apiToken.expiresAt && apiToken.expiresAt < new Date()) { + logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix }); + return null; + } + + // Update lastUsedAt (fire-and-forget) + prisma.apiToken.update({ + where: { id: apiToken.id }, + data: { lastUsedAt: new Date() }, + }).catch(() => {}); + + // Use the token's target user (userId), not the creator (createdById) + const user = apiToken.tokenUser; + return { + sub: user.id, + id: user.id, + plexId: user.plexId, + username: user.plexUsername, + role: apiToken.role, + }; +} + /** * Middleware: Require authentication - * Verifies JWT token and adds user to request + * Verifies JWT token or static API token and adds user to request */ export async function requireAuth( request: NextRequest, @@ -53,6 +94,26 @@ export async function requireAuth( ); } + // Check if this is a static API token + if (token.startsWith(API_TOKEN_PREFIX)) { + const apiUser = await authenticateApiToken(token); + if (!apiUser) { + logger.error('API token authentication failed'); + return NextResponse.json( + { + error: 'Unauthorized', + message: 'Invalid or expired API token', + }, + { status: 401 } + ); + } + + const authenticatedRequest = request as AuthenticatedRequest; + authenticatedRequest.user = apiUser; + return handler(authenticatedRequest); + } + + // Fall back to JWT verification const payload = verifyAccessToken(token); if (!payload) { From 04b6a2c135ff7d78a5e792be42a0d869aace381c Mon Sep 17 00:00:00 2001 From: Michael Borohovski Date: Tue, 3 Mar 2026 15:16:03 -0800 Subject: [PATCH 02/15] Harden API token auth for deleted users and add route rate limiting --- src/app/api/admin/api-tokens/[id]/route.ts | 14 ++++++++ src/app/api/admin/api-tokens/route.ts | 14 ++++++++ src/app/api/user/api-tokens/[id]/route.ts | 14 ++++++++ src/app/api/user/api-tokens/route.ts | 14 ++++++++ src/lib/middleware/auth.ts | 29 +++++++++++++-- src/lib/utils/apiTokenRateLimit.ts | 42 ++++++++++++++++++++++ 6 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/lib/utils/apiTokenRateLimit.ts diff --git a/src/app/api/admin/api-tokens/[id]/route.ts b/src/app/api/admin/api-tokens/[id]/route.ts index cfe7b38..e0b0e7a 100644 --- a/src/app/api/admin/api-tokens/[id]/route.ts +++ b/src/app/api/admin/api-tokens/[id]/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; const logger = RMABLogger.create('API.Admin.ApiTokens'); @@ -21,6 +22,19 @@ export async function DELETE( return requireAuth(request, (req: AuthenticatedRequest) => requireAdmin(req, async () => { try { + const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token revoke attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const { id } = await params; const token = await prisma.apiToken.findUnique({ where: { id } }); diff --git a/src/app/api/admin/api-tokens/route.ts b/src/app/api/admin/api-tokens/route.ts index 34bf58f..b28c826 100644 --- a/src/app/api/admin/api-tokens/route.ts +++ b/src/app/api/admin/api-tokens/route.ts @@ -8,6 +8,7 @@ import crypto from 'crypto'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; import { z } from 'zod'; const logger = RMABLogger.create('API.Admin.ApiTokens'); @@ -74,6 +75,19 @@ export async function POST(request: NextRequest) { return requireAuth(request, (req: AuthenticatedRequest) => requireAdmin(req, async () => { try { + const rateLimit = checkApiTokenCreateRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token create attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const body = await req.json(); const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body); diff --git a/src/app/api/user/api-tokens/[id]/route.ts b/src/app/api/user/api-tokens/[id]/route.ts index 771113c..4169218 100644 --- a/src/app/api/user/api-tokens/[id]/route.ts +++ b/src/app/api/user/api-tokens/[id]/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; const logger = RMABLogger.create('API.User.ApiTokens'); @@ -20,6 +21,19 @@ export async function DELETE( ) { return requireAuth(request, async (req: AuthenticatedRequest) => { try { + const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token revoke attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const { id } = await params; const token = await prisma.apiToken.findUnique({ where: { id } }); diff --git a/src/app/api/user/api-tokens/route.ts b/src/app/api/user/api-tokens/route.ts index 8ffa8af..234b54f 100644 --- a/src/app/api/user/api-tokens/route.ts +++ b/src/app/api/user/api-tokens/route.ts @@ -8,6 +8,7 @@ import crypto from 'crypto'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; import { z } from 'zod'; const logger = RMABLogger.create('API.User.ApiTokens'); @@ -57,6 +58,19 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { try { + const rateLimit = checkApiTokenCreateRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token create attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const body = await req.json(); const { name, expiresAt } = CreateTokenSchema.parse(body); diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index b42940d..c1e7f98 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -45,7 +45,17 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id const apiToken = await prisma.apiToken.findUnique({ where: { tokenHash }, - include: { tokenUser: { select: { id: true, plexId: true, plexUsername: true, role: true } } }, + include: { + tokenUser: { + select: { + id: true, + plexId: true, + plexUsername: true, + role: true, + deletedAt: true, + }, + }, + }, }); if (!apiToken) return null; @@ -56,6 +66,16 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id return null; } + // Reject tokens for soft-deleted users + const user = apiToken.tokenUser; + if (!user || user.deletedAt) { + logger.warn('API token used by deleted or missing user', { + tokenPrefix: apiToken.tokenPrefix, + userId: user?.id, + }); + return null; + } + // Update lastUsedAt (fire-and-forget) prisma.apiToken.update({ where: { id: apiToken.id }, @@ -63,7 +83,6 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id }).catch(() => {}); // Use the token's target user (userId), not the creator (createdById) - const user = apiToken.tokenUser; return { sub: user.id, id: user.id, @@ -130,9 +149,13 @@ export async function requireAuth( // Verify user still exists in database const user = await prisma.user.findUnique({ where: { id: payload.sub }, + select: { + id: true, + deletedAt: true, + }, }); - if (!user) { + if (!user || user.deletedAt) { logger.error('User not found in database', { userId: payload.sub }); return NextResponse.json( { diff --git a/src/lib/utils/apiTokenRateLimit.ts b/src/lib/utils/apiTokenRateLimit.ts new file mode 100644 index 0000000..9d01e2c --- /dev/null +++ b/src/lib/utils/apiTokenRateLimit.ts @@ -0,0 +1,42 @@ +type Bucket = { + count: number; + resetAt: number; +}; + +type RateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + +const buckets = new Map(); + +function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { + const now = Date.now(); + const current = buckets.get(key); + + if (!current || now >= current.resetAt) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) }; + } + + if (current.count >= maxRequests) { + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; + } + + current.count += 1; + return { + allowed: true, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; +} + +export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult { + return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000); +} + +export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult { + return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000); +} From f0b2476b8767f9ccff5ae470441364a7456db471 Mon Sep 17 00:00:00 2001 From: Michael Borohovski Date: Tue, 3 Mar 2026 15:45:07 -0800 Subject: [PATCH 03/15] Add tests for security hardening: deleted user auth rejection, rate limiting --- tests/helpers/prisma.ts | 1 + tests/middleware/auth.middleware.test.ts | 99 ++++++++++++++++++ tests/utils/apiTokenRateLimit.test.ts | 122 +++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 tests/utils/apiTokenRateLimit.test.ts diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index fc551c1..c816cc5 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -47,6 +47,7 @@ export const createPrismaMock = () => ({ bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), goodreadsBookMapping: createModelMock(), + apiToken: createModelMock(), $queryRaw: vi.fn(), $disconnect: vi.fn(), }); diff --git a/tests/middleware/auth.middleware.test.ts b/tests/middleware/auth.middleware.test.ts index e6f9436..4f3eee2 100644 --- a/tests/middleware/auth.middleware.test.ts +++ b/tests/middleware/auth.middleware.test.ts @@ -3,9 +3,11 @@ * Documentation: documentation/backend/services/auth.md */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { NextResponse } from 'next/server'; import { createPrismaMock } from '../helpers/prisma'; +import crypto from 'crypto'; const prismaMock = createPrismaMock(); const verifyAccessTokenMock = vi.fn(); @@ -29,6 +31,11 @@ const makeRequest = (authHeader?: string) => ({ }, }); +// Helper to create a valid API token hash for testing +const createTestApiToken = (token: string) => { + return crypto.createHash('sha256').update(token).digest('hex'); +}; + describe('auth middleware', () => { beforeEach(() => { vi.clearAllMocks(); @@ -159,6 +166,98 @@ describe('auth middleware', () => { expect(result).toBe(true); }); + it('rejects JWT tokens for soft-deleted users', async () => { + verifyAccessTokenMock.mockReturnValue({ + sub: 'user-1', + plexId: 'plex-1', + username: 'user', + role: 'user', + iat: 1, + exp: 2, + }); + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: new Date(), + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/user not found/i); + }); + + describe('API token authentication', () => { + const testToken = 'rmab_test1234567890abcdef'; + const testTokenHash = createTestApiToken(testToken); + + it('rejects API tokens for soft-deleted users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: { + id: 'user-1', + plexUsername: 'deleteduser', + role: 'user', + deletedAt: new Date(), + }, + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/invalid.*expired/i); + }); + + it('rejects API tokens for missing users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: null, + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/invalid.*expired/i); + }); + + it('accepts valid API tokens for active users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: { + id: 'user-1', + plexUsername: 'activeuser', + role: 'user', + deletedAt: null, + }, + }); + prismaMock.apiToken.update.mockResolvedValue({}); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const handler = vi.fn(async (req: any) => + NextResponse.json({ ok: true, userId: req.user?.id }) + ); + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, handler); + const payload = await response.json(); + + expect(handler).toHaveBeenCalled(); + expect(payload.userId).toBe('user-1'); + }); + }); + it('returns current user from token', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', diff --git a/tests/utils/apiTokenRateLimit.test.ts b/tests/utils/apiTokenRateLimit.test.ts new file mode 100644 index 0000000..a80c9d8 --- /dev/null +++ b/tests/utils/apiTokenRateLimit.test.ts @@ -0,0 +1,122 @@ +/** + * Component: API Token Rate Limit Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkApiTokenCreateRateLimit, + checkApiTokenRevokeRateLimit, +} from '@/lib/utils/apiTokenRateLimit'; + +describe('API Token Rate Limiting', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('checkApiTokenCreateRateLimit', () => { + it('allows requests under the limit', () => { + const actorId = 'user-create-1'; + + for (let i = 0; i < 10; i++) { + const result = checkApiTokenCreateRateLimit(actorId); + expect(result.allowed).toBe(true); + } + }); + + it('blocks requests over the limit (10/min)', () => { + const actorId = 'user-create-2'; + + // Use up the limit + for (let i = 0; i < 10; i++) { + checkApiTokenCreateRateLimit(actorId); + } + + // 11th request should be blocked + const result = checkApiTokenCreateRateLimit(actorId); + expect(result.allowed).toBe(false); + expect(result.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('resets after the window expires', () => { + const actorId = 'user-create-3'; + + // Use up the limit + for (let i = 0; i < 10; i++) { + checkApiTokenCreateRateLimit(actorId); + } + + // Should be blocked + expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(false); + + // Advance time past the window (60 seconds) + vi.advanceTimersByTime(61 * 1000); + + // Should be allowed again + expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(true); + }); + + it('tracks different actors separately', () => { + const actor1 = 'user-create-4'; + const actor2 = 'user-create-5'; + + // Use up actor1's limit + for (let i = 0; i < 10; i++) { + checkApiTokenCreateRateLimit(actor1); + } + + // actor1 should be blocked + expect(checkApiTokenCreateRateLimit(actor1).allowed).toBe(false); + + // actor2 should still be allowed + expect(checkApiTokenCreateRateLimit(actor2).allowed).toBe(true); + }); + }); + + describe('checkApiTokenRevokeRateLimit', () => { + it('allows requests under the limit', () => { + const actorId = 'user-revoke-1'; + + for (let i = 0; i < 20; i++) { + const result = checkApiTokenRevokeRateLimit(actorId); + expect(result.allowed).toBe(true); + } + }); + + it('blocks requests over the limit (20/min)', () => { + const actorId = 'user-revoke-2'; + + // Use up the limit + for (let i = 0; i < 20; i++) { + checkApiTokenRevokeRateLimit(actorId); + } + + // 21st request should be blocked + const result = checkApiTokenRevokeRateLimit(actorId); + expect(result.allowed).toBe(false); + expect(result.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('returns correct retryAfterSeconds', () => { + const actorId = 'user-revoke-3'; + + // Use up the limit + for (let i = 0; i < 20; i++) { + checkApiTokenRevokeRateLimit(actorId); + } + + // Advance 30 seconds into the window + vi.advanceTimersByTime(30 * 1000); + + const result = checkApiTokenRevokeRateLimit(actorId); + expect(result.allowed).toBe(false); + // Should have ~30 seconds left + expect(result.retryAfterSeconds).toBeLessThanOrEqual(30); + expect(result.retryAfterSeconds).toBeGreaterThan(0); + }); + }); +}); From d0ce485bdc7e9d5fe508c5199b45dfaa9e18521d Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 12:19:37 -0500 Subject: [PATCH 04/15] Enrich audiobook metadata from Audnexus Query Audnexus (Audible) to backfill missing metadata during manual imports and file organization. Adds getAudibleService imports and calls to fetch audiobook details by ASIN, then backfills series, seriesPart, seriesAsin, year (from releaseDate) and narrator when missing and updates the DB. Failures are non-fatal and logged; logs were added to surface enrichment steps. Also uses the resolved series/seriesPart when building organization metadata. --- src/app/api/admin/manual-import/route.ts | 43 +++++++ .../processors/organize-files.processor.ts | 112 +++++++++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/manual-import/route.ts b/src/app/api/admin/manual-import/route.ts index d2aa482..dfbb6d0 100644 --- a/src/app/api/admin/manual-import/route.ts +++ b/src/app/api/admin/manual-import/route.ts @@ -12,6 +12,7 @@ import { prisma } from '@/lib/db'; import { getJobQueueService } from '@/lib/services/job-queue.service'; import { RMABLogger } from '@/lib/utils/logger'; import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats'; +import { getAudibleService } from '@/lib/integrations/audible.service'; const logger = RMABLogger.create('API.Admin.ManualImport'); @@ -174,6 +175,48 @@ export async function POST(request: NextRequest) { ); } + // Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts) + if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) { + try { + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin); + + if (audnexusData) { + const updates: Record = {}; + + if (!audiobook.series && audnexusData.series) { + updates.series = audnexusData.series; + } + if (!audiobook.seriesPart && audnexusData.seriesPart) { + updates.seriesPart = audnexusData.seriesPart; + } + if (!audiobook.seriesAsin && audnexusData.seriesAsin) { + updates.seriesAsin = audnexusData.seriesAsin; + } + if (!audiobook.year && audnexusData.releaseDate) { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + updates.year = releaseYear; + } + } + if (!audiobook.narrator && audnexusData.narrator) { + updates.narrator = audnexusData.narrator; + } + + if (Object.keys(updates).length > 0) { + await prisma.audiobook.update({ + where: { id: audiobook.id }, + data: updates, + }); + logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates); + } + } + } catch (error) { + // Non-fatal: series enrichment failure should never block the import + logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`); + } + } + // Check for existing requests const existingRequest = await prisma.request.findFirst({ where: { diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 3e6252b..99c3d56 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -15,6 +15,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { generateFilesHash } from '../utils/files-hash'; import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer'; import { removeEmptyParentDirectories } from '../utils/cleanup-helpers'; +import { getAudibleService } from '../integrations/audible.service'; /** * Process organize files job @@ -118,7 +119,62 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi } } - logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`) + // Enrich missing series data from Audnexus (safety net for records created without series) + let series = audiobook.series || undefined; + let seriesPart = audiobook.seriesPart || undefined; + + if (audiobook.audibleAsin && !series) { + try { + logger.info(`Missing series data, fetching from Audnexus for ASIN: ${audiobook.audibleAsin}`); + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin); + + if (audnexusData) { + const updates: Record = {}; + + if (audnexusData.series) { + series = audnexusData.series; + updates.series = series; + logger.info(`Got series "${series}" from Audnexus`); + } + if (audnexusData.seriesPart) { + seriesPart = audnexusData.seriesPart; + updates.seriesPart = seriesPart; + logger.info(`Got seriesPart "${seriesPart}" from Audnexus`); + } + if (audnexusData.seriesAsin) { + updates.seriesAsin = audnexusData.seriesAsin; + } + // Also backfill year/narrator if still missing + if (!year && audnexusData.releaseDate) { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + updates.year = year; + logger.info(`Got year ${year} from Audnexus`); + } + } + if (!narrator && audnexusData.narrator) { + narrator = audnexusData.narrator; + updates.narrator = narrator; + logger.info(`Got narrator "${narrator}" from Audnexus`); + } + + if (Object.keys(updates).length > 0) { + await prisma.audiobook.update({ + where: { id: audiobookId }, + data: updates, + }); + logger.info(`Updated audiobook record with Audnexus metadata`); + } + } + } catch (error) { + // Non-fatal: missing series won't block organization, just degrades path quality + logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`); // Get file organizer (reads media_dir from database config) const organizer = await getFileOrganizer(); @@ -151,8 +207,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi coverArtUrl: audiobook.coverArtUrl || undefined, asin: audiobook.audibleAsin || undefined, year, - series: audiobook.series || undefined, - seriesPart: audiobook.seriesPart || undefined, + series, + seriesPart, }, template, jobId ? { jobId, context: 'FileOrganizer' } : undefined, @@ -545,6 +601,56 @@ async function processEbookOrganization( } } + // Enrich missing series data from Audnexus (safety net for records created without series) + if (book.audibleAsin && !series) { + try { + logger.info(`Missing series data for ebook, fetching from Audnexus for ASIN: ${book.audibleAsin}`); + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(book.audibleAsin); + + if (audnexusData) { + const updates: Record = {}; + + if (audnexusData.series) { + series = audnexusData.series; + updates.series = series; + logger.info(`Got series "${series}" from Audnexus`); + } + if (audnexusData.seriesPart) { + seriesPart = audnexusData.seriesPart; + updates.seriesPart = seriesPart; + logger.info(`Got seriesPart "${seriesPart}" from Audnexus`); + } + if (audnexusData.seriesAsin) { + updates.seriesAsin = audnexusData.seriesAsin; + } + if (!year && audnexusData.releaseDate) { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + updates.year = year; + logger.info(`Got year ${year} from Audnexus`); + } + } + if (!narrator && audnexusData.narrator) { + narrator = audnexusData.narrator; + updates.narrator = narrator; + logger.info(`Got narrator "${narrator}" from Audnexus`); + } + + if (Object.keys(updates).length > 0) { + await prisma.audiobook.update({ + where: { id: audiobookId }, + data: updates, + }); + logger.info(`Updated book record with Audnexus metadata`); + } + } + } catch (error) { + logger.warn(`Failed to fetch Audnexus data for ASIN ${book.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`); + } + } + logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`); // Check if this is an indexer download (needs to keep source for seeding) From 441724c378ed08cf6050879fba7291bf9c7023c8 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 12:47:09 -0500 Subject: [PATCH 05/15] Normalize local usernames to lowercase Normalize local account usernames by trimming and lowercasing across the stack. Added a Prisma migration to lowercase existing plex_username and rewrite local plex_id values for non-deleted accounts. Updated LocalAuthProvider, admin login route, and setup completion to use normalized usernames when looking up, creating, and storing users (including plexId `local-{username}`). Added/updated tests to assert case-insensitive lookups, storage of lowercased usernames/plexIds, and duplicate username rejection. --- .../migration.sql | 3 + src/app/api/auth/admin/login/route.ts | 4 +- src/app/api/setup/complete/route.ts | 5 +- src/lib/services/auth/LocalAuthProvider.ts | 13 ++-- .../services/auth/local-auth-provider.test.ts | 70 +++++++++++++++++++ 5 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260304000000_normalize_local_usernames/migration.sql diff --git a/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql b/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql new file mode 100644 index 0000000..5fbb0c2 --- /dev/null +++ b/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql @@ -0,0 +1,3 @@ +-- Normalize existing local usernames to lowercase +UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL; +UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%'; diff --git a/src/app/api/auth/admin/login/route.ts b/src/app/api/auth/admin/login/route.ts index 291bb20..46310c5 100644 --- a/src/app/api/auth/admin/login/route.ts +++ b/src/app/api/auth/admin/login/route.ts @@ -38,9 +38,11 @@ export async function POST(request: NextRequest) { ); } + const normalizedUsername = username.trim().toLowerCase(); + // Find user by local admin identifier const user = await prisma.user.findUnique({ - where: { plexId: `local-${username}` }, + where: { plexId: `local-${normalizedUsername}` }, }); if (!user) { diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts index 24dd593..637e0da 100644 --- a/src/app/api/setup/complete/route.ts +++ b/src/app/api/setup/complete/route.ts @@ -140,14 +140,15 @@ export async function POST(request: NextRequest) { ); } + const normalizedAdminUsername = admin.username.trim().toLowerCase(); const hashedPassword = await bcrypt.hash(admin.password, 10); const encryptionService = getEncryptionService(); const encryptedPassword = encryptionService.encrypt(hashedPassword); adminUser = await prisma.user.create({ data: { - plexId: `local-${admin.username}`, - plexUsername: admin.username, + plexId: `local-${normalizedAdminUsername}`, + plexUsername: normalizedAdminUsername, plexEmail: null, role: 'admin', isSetupAdmin: true, // Mark as setup admin - role cannot be changed diff --git a/src/lib/services/auth/LocalAuthProvider.ts b/src/lib/services/auth/LocalAuthProvider.ts index 1fbc929..c3dddb6 100644 --- a/src/lib/services/auth/LocalAuthProvider.ts +++ b/src/lib/services/auth/LocalAuthProvider.ts @@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider { return { success: false, error: 'Username and password required' }; } + const normalizedUsername = username.trim().toLowerCase(); + // Find user (exclude soft-deleted users) const user = await prisma.user.findFirst({ where: { - plexUsername: username, + plexUsername: normalizedUsername, authProvider: 'local', deletedAt: null, // Exclude soft-deleted users }, @@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider { async register(params: RegisterParams): Promise { try { const { username, password } = params; + const normalizedUsername = username?.trim().toLowerCase(); // Validate - if (!username || username.length < 3) { + if (!normalizedUsername || normalizedUsername.length < 3) { return { success: false, error: 'Username must be at least 3 characters' }; } @@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider { // Check username uniqueness (only among non-deleted users) const existing = await prisma.user.findFirst({ where: { - plexUsername: username, + plexUsername: normalizedUsername, authProvider: 'local', deletedAt: null, // Allow reuse of usernames from deleted accounts }, @@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider { // Create user const user = await prisma.user.create({ data: { - plexId: `local-${username}`, - plexUsername: username, + plexId: `local-${normalizedUsername}`, + plexUsername: normalizedUsername, authToken: encryptedHash, authProvider: 'local', role: isFirstUser ? 'admin' : 'user', diff --git a/tests/services/auth/local-auth-provider.test.ts b/tests/services/auth/local-auth-provider.test.ts index ff7a16e..f1e0a11 100644 --- a/tests/services/auth/local-auth-provider.test.ts +++ b/tests/services/auth/local-auth-provider.test.ts @@ -167,6 +167,31 @@ describe('LocalAuthProvider', () => { expect(result.error).toMatch(/invalid username or password/i); }); + it('normalizes username to lowercase on login', async () => { + prismaMock.user.findFirst.mockResolvedValue({ + id: 'user-ci', + plexId: 'local-admin', + plexUsername: 'admin', + role: 'admin', + authProvider: 'local', + authToken: 'enc:hash', + registrationStatus: 'approved', + deletedAt: null, + }); + prismaMock.user.update.mockResolvedValue({}); + bcryptCompare.mockResolvedValue(true); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + await provider.handleCallback({ username: 'Admin', password: 'pass' }); + + expect(prismaMock.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ plexUsername: 'admin' }), + }) + ); + }); + it('blocks registration when disabled', async () => { configMock.get.mockResolvedValueOnce('false'); @@ -237,6 +262,51 @@ describe('LocalAuthProvider', () => { expect(result.error).toContain('Username already taken'); }); + it('stores lowercase username and plexId on registration', async () => { + configMock.get.mockResolvedValueOnce('true'); // registration enabled + configMock.get.mockResolvedValueOnce('false'); // no admin approval + prismaMock.user.findFirst.mockResolvedValue(null); + prismaMock.user.count.mockResolvedValue(1); + prismaMock.user.create.mockResolvedValue({ + id: 'user-ci2', + plexId: 'local-myuser', + plexUsername: 'myuser', + role: 'user', + }); + bcryptHash.mockResolvedValue('hash'); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + await provider.register({ username: 'MyUser', password: 'password123' }); + + expect(prismaMock.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + plexId: 'local-myuser', + plexUsername: 'myuser', + }), + }) + ); + }); + + it('rejects duplicate username case-insensitively on registration', async () => { + configMock.get.mockResolvedValueOnce('true'); // registration enabled + prismaMock.user.findFirst.mockResolvedValue({ id: 'user-existing' }); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'User', password: 'password123' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Username already taken'); + // The lookup should use the lowercased username + expect(prismaMock.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ plexUsername: 'user' }), + }) + ); + }); + it('creates admin user on first registration', async () => { configMock.get.mockResolvedValueOnce('true'); // registration enabled configMock.get.mockResolvedValueOnce('false'); // no admin approval From d6eca611fc47a6cd7c3f50e1b6d270bbf3bb20d5 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 14:51:23 -0500 Subject: [PATCH 06/15] Add API tokens management, docs & UI Introduce full API token support: add a Prisma migration to create api_tokens table and indexes; add types, constants and a generateApiToken utility (hashed token + prefix). Update admin and user token routes to use the generator, enforce per-user active token caps, and integrate rate-limit checks. Add an interactive API docs page with TokenInput, EndpointCard and ResponseViewer components, plus a protected page route. Improve confirmation UX with an accessible ConfirmDialog (focus trap, Escape to close, animations) and wire confirm flows into admin/profile token sections; also update ConfirmModal to accept node messages. Add dialog CSS animations and enhance clipboard error handling. Update related middleware, utils and tests to reflect changes. --- .../migration.sql | 33 +++ src/app/admin/components/ConfirmDialog.tsx | 227 ++++++++++++------ src/app/admin/settings/tabs/ApiTab/ApiTab.tsx | 63 +++-- src/app/api-docs/page.tsx | 141 +++++++++++ src/app/api/admin/api-tokens/route.ts | 29 ++- src/app/api/user/api-tokens/route.ts | 29 ++- src/app/globals.css | 25 ++ src/components/api-docs/EndpointCard.tsx | 157 ++++++++++++ src/components/api-docs/ResponseViewer.tsx | 151 ++++++++++++ src/components/api-docs/TokenInput.tsx | 104 ++++++++ src/components/profile/ApiTokensSection.tsx | 57 +++-- src/components/ui/ConfirmModal.tsx | 6 +- src/lib/constants/api-tokens.ts | 107 +++++++++ src/lib/middleware/auth.ts | 20 +- src/lib/types/api-tokens.ts | 23 ++ src/lib/utils/api-token.ts | 30 +++ src/lib/utils/apiTokenRateLimit.ts | 50 ++++ tests/middleware/auth.middleware.test.ts | 99 +++++++- tests/utils/apiTokenRateLimit.test.ts | 85 ++++++- 19 files changed, 1300 insertions(+), 136 deletions(-) create mode 100644 prisma/migrations/20260305000000_add_api_tokens_table/migration.sql create mode 100644 src/app/api-docs/page.tsx create mode 100644 src/components/api-docs/EndpointCard.tsx create mode 100644 src/components/api-docs/ResponseViewer.tsx create mode 100644 src/components/api-docs/TokenInput.tsx create mode 100644 src/lib/constants/api-tokens.ts create mode 100644 src/lib/types/api-tokens.ts create mode 100644 src/lib/utils/api-token.ts diff --git a/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql b/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql new file mode 100644 index 0000000..2950e36 --- /dev/null +++ b/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "api_tokens" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "token_prefix" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + "created_by_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "last_used_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "api_tokens_created_by_id_idx" ON "api_tokens"("created_by_id"); + +-- CreateIndex +CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id"); + +-- AddForeignKey +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/app/admin/components/ConfirmDialog.tsx b/src/app/admin/components/ConfirmDialog.tsx index ef71e08..6e1e10d 100644 --- a/src/app/admin/components/ConfirmDialog.tsx +++ b/src/app/admin/components/ConfirmDialog.tsx @@ -2,12 +2,13 @@ * Component: Confirm Dialog * Documentation: documentation/frontend/components.md * - * Reusable confirmation dialog for destructive actions + * Reusable confirmation dialog for destructive actions. + * Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA. */ 'use client'; -import { Fragment } from 'react'; +import React, { useEffect, useRef } from 'react'; export interface ConfirmDialogProps { isOpen: boolean; @@ -30,99 +31,177 @@ export function ConfirmDialog({ onConfirm, onCancel, }: ConfirmDialogProps) { + const cancelRef = useRef(null); + const confirmRef = useRef(null); + const dialogRef = useRef(null); + + // Focus the cancel button on open (safer default for destructive dialogs) + useEffect(() => { + if (isOpen) { + // Small delay to let animation start before stealing focus + const t = setTimeout(() => cancelRef.current?.focus(), 50); + return () => clearTimeout(t); + } + }, [isOpen]); + + // Escape to close + focus trap + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + return; + } + + // Focus trap: tab cycles only within dialog + if (e.key === 'Tab') { + const focusable = dialogRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (!focusable || focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onCancel]); + + // Prevent body scroll while open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + } + }, [isOpen]); + if (!isOpen) return null; - const confirmButtonClasses = - confirmVariant === 'danger' - ? 'bg-red-600 hover:bg-red-700 text-white' - : 'bg-blue-600 hover:bg-blue-700 text-white'; + const isDestructive = confirmVariant === 'danger'; return ( -
+
{/* Backdrop */}
+ + {/* Revoke confirmation dialog */} + + Are you sure you want to revoke{' '} + + “{tokens.find((t) => t.id === confirmRevokeId)?.name ?? 'this token'}” + + ? Any integrations using this token will immediately lose access. This cannot be undone. + + } + confirmText="Revoke token" + cancelText="Cancel" + variant="danger" + onConfirm={handleDeleteConfirmed} + onClose={() => setConfirmRevokeId(null)} + /> ); } diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 431b48d..19ea0ef 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -14,7 +14,7 @@ interface ConfirmModalProps { onClose: () => void; onConfirm: () => void; title: string; - message: string; + message: string | React.ReactNode; confirmText?: string; cancelText?: string; isLoading?: boolean; @@ -35,7 +35,9 @@ export function ConfirmModal({ return (
-

{message}

+
+ {typeof message === 'string' ?

{message}

: message} +