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' && (
)}
+
+ {/* 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}
+
+
+ {copied ? 'Copied!' : 'Copy'}
+
+
+
+
setCreatedToken(null)}
+ className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
+ >
+
+
+
+
+ )}
+
+ {/* 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()}
+ />
+
+
+
+
+
+
+
+
+ {creating ? 'Creating...' : 'Create Token'}
+
+ { setShowCreateForm(false); setNewTokenName(''); setNewTokenExpiry('never'); }}
+ className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
+ >
+ Cancel
+
+
+
+ ) : (
+
setShowCreateForm(true)}
+ className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
+ >
+ Create New Token
+
+ )}
+
+ {/* Token list */}
+ {loading ? (
+
+ ) : tokens.length === 0 ? (
+
+
+
No API tokens yet
+
Create a token to enable programmatic API access
+
+ ) : (
+
+
+
+
+ | Name |
+ Token |
+ Last Used |
+ Expires |
+ Actions |
+
+
+
+ {tokens.map((token) => (
+
+ | {token.name} |
+
+
+ {token.tokenPrefix}...
+
+ |
+ {formatDate(token.lastUsedAt)} |
+
+ {token.expiresAt ? (
+
+ {formatDate(token.expiresAt)}
+ {new Date(token.expiresAt) < new Date() && ' (expired)'}
+
+ ) : (
+ 'Never'
+ )}
+ |
+
+ handleDelete(token.id)}
+ disabled={deletingId === token.id}
+ className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {deletingId === token.id ? 'Revoking...' : 'Revoke'}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {/* 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) {