mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge branch 'main' into feature/hardover-shelves
This commit is contained in:
@@ -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-%';
|
||||
@@ -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;
|
||||
@@ -69,6 +69,8 @@ model User {
|
||||
hardcoverShelves HardcoverShelf[]
|
||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||
watchedSeries WatchedSeries[]
|
||||
watchedAuthors WatchedAuthor[]
|
||||
|
||||
@@ -499,6 +501,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")
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(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<HTMLElement>(
|
||||
'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 (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-desc"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
className="animate-dialog-backdrop fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-100 dark:bg-red-900'
|
||||
: 'bg-blue-100 dark:bg-blue-900'
|
||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
||||
>
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="animate-dialog-panel relative w-full max-w-sm rounded-2xl overflow-hidden bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/10 dark:ring-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon well */}
|
||||
<div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
isDestructive
|
||||
? 'bg-red-50 dark:bg-red-500/10'
|
||||
: 'bg-blue-50 dark:bg-blue-500/10'
|
||||
}`}>
|
||||
{isDestructive ? (
|
||||
<svg
|
||||
className={`h-6 w-6 ${
|
||||
confirmVariant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
className="w-5 h-5 text-red-500 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.75"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{confirmVariant === 'danger' ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-500 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.75"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h3
|
||||
id="confirm-dialog-title"
|
||||
className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div id="confirm-dialog-desc" className="mt-1.5">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 bg-gray-50/80 dark:bg-white/[0.03] border-t border-gray-100 dark:border-white/[0.06]">
|
||||
<button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium rounded-xl text-gray-700 dark:text-gray-300 bg-white dark:bg-white/[0.06] hover:bg-gray-100 dark:hover:bg-white/[0.1] border border-gray-200 dark:border-white/[0.1] transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-xl text-white transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 active:scale-[0.97] ${
|
||||
isDestructive
|
||||
? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,10 +62,9 @@ export function RequestActionsDropdown({
|
||||
// View Details: available when ASIN exists (audiobook requests only)
|
||||
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
||||
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
// Determine available actions based on status
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
@@ -130,7 +129,11 @@ export function RequestActionsDropdown({
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearch(true);
|
||||
if (isEbook) {
|
||||
setShowInteractiveSearchEbook(true);
|
||||
} else {
|
||||
setShowInteractiveSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdjustSearchTerms = () => {
|
||||
@@ -513,6 +516,7 @@ export function RequestActionsDropdown({
|
||||
author: request.author,
|
||||
}}
|
||||
searchMode="ebook"
|
||||
customSearchTerms={request.customSearchTerms}
|
||||
/>
|
||||
|
||||
{/* Adjust Search Terms Modal */}
|
||||
|
||||
@@ -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: '🔑' },
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' && <NotificationsTab />}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && <ApiTab />}
|
||||
|
||||
{/* Save Button (only for tabs that save through main page) */}
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Component: API Token Management Tab (Admin)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
|
||||
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
import type { AdminApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function ApiTab() {
|
||||
const api = useApiTokens<AdminApiToken>({ basePath: '/api/admin/api-tokens' });
|
||||
|
||||
// Admin-specific state
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [newTokenUserId, setNewTokenUserId] = useState('');
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/users');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, user selector just won't populate
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const extraBody: Record<string, string> = {};
|
||||
if (newTokenUserId) extraBody.userId = newTokenUserId;
|
||||
const created = await api.handleCreate(extraBody);
|
||||
// Reset admin-specific fields only when create succeeds
|
||||
if (created) {
|
||||
setNewTokenUserId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
api.resetForm();
|
||||
setNewTokenUserId('');
|
||||
};
|
||||
|
||||
if (api.loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage API tokens for all users. Create tokens for any user for programmatic access.{' '}
|
||||
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View API documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{api.error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{api.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{api.createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{api.createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={api.handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{api.copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss token banner"
|
||||
onClick={api.dismissCreatedToken}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{api.showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={api.newTokenName}
|
||||
onChange={(e) => api.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()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={api.newTokenExpiry}
|
||||
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
User (acts as)
|
||||
</label>
|
||||
<select
|
||||
value={newTokenUserId}
|
||||
onChange={(e) => setNewTokenUserId(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">Current user (default)</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.plexUsername} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Token will inherit the selected user's role
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={api.creating || !api.newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{api.creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => api.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
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{api.tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Acts As</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Role</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Created By</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{api.tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.tokenUser}</td>
|
||||
<td className="py-3 px-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
token.role === 'admin'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{token.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{api.formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||
disabled={api.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"
|
||||
>
|
||||
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Revoke confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={api.confirmRevokeId !== null}
|
||||
title="Revoke API token"
|
||||
message={
|
||||
<>
|
||||
Are you sure you want to revoke{' '}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</span>
|
||||
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Revoke token"
|
||||
cancelLabel="Cancel"
|
||||
confirmVariant="danger"
|
||||
onConfirm={api.handleDeleteConfirmed}
|
||||
onCancel={() => api.setConfirmRevokeId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Component: Interactive API Documentation Page
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Lists all API token-accessible endpoints with "Try it out" functionality.
|
||||
* Users can test with a custom API token or their current browser session.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { TokenInput } from '@/components/api-docs/TokenInput';
|
||||
import { EndpointCard } from '@/components/api-docs/EndpointCard';
|
||||
import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { user } = useAuth();
|
||||
const [token, setToken] = useState('');
|
||||
const [useSession, setUseSession] = useState(false);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 pt-8 pb-16">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-gray-900 dark:text-white font-medium">API Documentation</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
API Reference
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-500 dark:text-gray-400 leading-relaxed max-w-2xl">
|
||||
Interact with ReadMeABook programmatically using API tokens. These endpoints are
|
||||
available for external integrations, dashboards, and automation tools.
|
||||
</p>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="flex flex-wrap gap-3 mt-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Manage your tokens
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Admin token management
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication section */}
|
||||
<div className="mb-8">
|
||||
<TokenInput
|
||||
token={token}
|
||||
onTokenChange={setToken}
|
||||
useSession={useSession}
|
||||
onUseSessionChange={setUseSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage instructions card */}
|
||||
<div className="mb-8 rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Quick Start
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Include your API token in the <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-900 rounded text-xs font-mono">Authorization</code> header as a Bearer token:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-xl overflow-x-auto font-mono leading-relaxed">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Endpoints section header */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Available Endpoints
|
||||
</h2>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||
{API_TOKEN_ENDPOINT_DOCS.length} endpoints
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Endpoint cards */}
|
||||
<div className="space-y-4">
|
||||
{API_TOKEN_ENDPOINT_DOCS.map((endpoint) => (
|
||||
<EndpointCard
|
||||
key={`${endpoint.method}:${endpoint.path}`}
|
||||
endpoint={endpoint}
|
||||
token={token}
|
||||
useSession={useSession}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="mt-10 text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
API tokens are restricted to the endpoints listed above.
|
||||
JWT session authentication has access to all endpoints.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
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 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 } });
|
||||
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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Component: Admin API Token Management Routes
|
||||
* 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';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
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(), // Accepted for compatibility, but cannot differ from target user 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.
|
||||
* Token role is always derived from the target user's current role.
|
||||
* Returns the full token ONCE.
|
||||
*/
|
||||
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);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||
const activeTokenCount = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: targetUserId,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Security guard: token role must always match the target user's persisted role.
|
||||
// This avoids role/identity mismatch (for example: acting as user A with admin role).
|
||||
if (role && role !== targetUser.role) {
|
||||
logger.warn('Admin attempted token role override that differs from target user role', {
|
||||
requestedRole: role,
|
||||
userActualRole: targetUser.role,
|
||||
targetUser: targetUser.plexUsername,
|
||||
createdBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Token role must match target user's role (${targetUser.role}).`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tokenRole = targetUser.role;
|
||||
|
||||
// Generate the token
|
||||
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||
|
||||
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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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<string, any> = {};
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -71,41 +71,56 @@ export async function POST(
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const customTitle = body.customTitle as string | undefined;
|
||||
|
||||
// Get the parent audiobook request
|
||||
const parentRequest = await prisma.request.findUnique({
|
||||
// Get the request (can be audiobook parent or direct ebook request)
|
||||
const requestRecord = await prisma.request.findUnique({
|
||||
where: { id: parentRequestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!parentRequest) {
|
||||
if (!requestRecord) {
|
||||
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parentRequest.type !== 'audiobook') {
|
||||
return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 });
|
||||
// Support two flows:
|
||||
// Flow A (sidecar): Audiobook request in downloaded/available state
|
||||
// Flow B (direct): Ebook request in pending/failed/awaiting_search state
|
||||
const isDirectEbookSearch = requestRecord.type === 'ebook';
|
||||
const isAudiobookSidecar = requestRecord.type === 'audiobook';
|
||||
|
||||
if (!isDirectEbookSearch && !isAudiobookSidecar) {
|
||||
return NextResponse.json({ error: 'Invalid request type' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['downloaded', 'available'].includes(parentRequest.status)) {
|
||||
if (isAudiobookSidecar && !['downloaded', 'available'].includes(requestRecord.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot search ebooks for request in ${parentRequest.status} status` },
|
||||
{ error: `Cannot search ebooks for audiobook request in ${requestRecord.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing non-retryable ebook request
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (isDirectEbookSearch && !['pending', 'failed', 'awaiting_search'].includes(requestRecord.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot search for ebook request in ${requestRecord.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
existingRequestId: existingEbookRequest.id,
|
||||
}, { status: 400 });
|
||||
// Check for existing child ebook requests (sidecar mode only)
|
||||
if (isAudiobookSidecar) {
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
existingRequestId: existingEbookRequest.id,
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Get ebook configuration
|
||||
@@ -135,10 +150,10 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const audiobook = parentRequest.audiobook;
|
||||
const audiobook = requestRecord.audiobook;
|
||||
const searchTitle = customTitle || audiobook.title;
|
||||
|
||||
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`);
|
||||
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author} (${isDirectEbookSearch ? 'direct' : 'sidecar'})`);
|
||||
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
|
||||
|
||||
// Search both sources in parallel
|
||||
|
||||
@@ -64,14 +64,20 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger search job
|
||||
// Trigger appropriate search job based on request type
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(id, {
|
||||
const audiobookData = {
|
||||
id: requestRecord.audiobook.id,
|
||||
title: requestRecord.audiobook.title,
|
||||
author: requestRecord.audiobook.author,
|
||||
asin: requestRecord.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (requestRecord.type === 'ebook') {
|
||||
await jobQueue.addSearchEbookJob(id, audiobookData);
|
||||
} else {
|
||||
await jobQueue.addSearchJob(id, audiobookData);
|
||||
}
|
||||
|
||||
// Update request status
|
||||
const updated = await prisma.request.update({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
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 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 } });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Component: User API Token Routes (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';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||
|
||||
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 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);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||
const activeTokenCount = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: req.user!.id,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate the token
|
||||
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -300,7 +300,7 @@ export default function BookDatePage() {
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/settings')}
|
||||
onClick={() => router.push('/admin/settings')}
|
||||
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Go to Settings
|
||||
@@ -415,6 +415,7 @@ export default function BookDatePage() {
|
||||
isAvailable={currentRec.isAvailable}
|
||||
requestedByUsername={currentRec.requestedByUsername}
|
||||
hideRequestActions
|
||||
aiReason={currentRec.aiReason}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
@@ -197,6 +197,31 @@ body {
|
||||
animation: toast-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Confirmation Dialog */
|
||||
@keyframes dialog-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-dialog-backdrop {
|
||||
animation: dialog-backdrop-in 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-dialog-panel {
|
||||
animation: dialog-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Hide scrollbar while keeping scroll functional */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { ShelvesSection } from '@/components/profile/ShelvesSection';
|
||||
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
|
||||
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
||||
|
||||
const statConfig = [
|
||||
@@ -240,6 +241,9 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* API Tokens */}
|
||||
<ApiTokensSection />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Component: API Docs Endpoint Card
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Expandable card for a single API endpoint with "Try it out" functionality.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ResponseViewer } from './ResponseViewer';
|
||||
import type { EndpointDoc } from '@/lib/constants/api-tokens';
|
||||
|
||||
interface EndpointCardProps {
|
||||
endpoint: EndpointDoc;
|
||||
token: string;
|
||||
useSession: boolean;
|
||||
}
|
||||
|
||||
const METHOD_STYLES: Record<string, string> = {
|
||||
GET: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
|
||||
POST: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
PUT: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
|
||||
DELETE: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',
|
||||
};
|
||||
|
||||
export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<number | null>(null);
|
||||
const [data, setData] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleTryIt = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
setStatus(null);
|
||||
setExpanded(true);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
if (useSession) {
|
||||
// Use session JWT via fetchWithAuth
|
||||
response = await fetchWithAuth(endpoint.path, { method: endpoint.method });
|
||||
} else {
|
||||
// Use custom API token
|
||||
if (!token.trim()) {
|
||||
setError('Please enter an API token');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
response = await fetch(endpoint.path, {
|
||||
method: endpoint.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(response.status);
|
||||
const text = await response.text();
|
||||
setData(text);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Request failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, token, useSession]);
|
||||
|
||||
const methodStyle = METHOD_STYLES[endpoint.method] || METHOD_STYLES.GET;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 shadow-sm overflow-hidden transition-shadow hover:shadow-md">
|
||||
{/* Card header */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold tracking-wide ${methodStyle}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-sm font-mono font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
{endpoint.requiresAdmin && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{endpoint.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{endpoint.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTryIt}
|
||||
disabled={loading}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 transition-all active:scale-[0.97]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
|
||||
Running
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Try it
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable response area */}
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
expanded ? 'max-h-[600px] opacity-100 mt-1' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<ResponseViewer
|
||||
status={status}
|
||||
data={data}
|
||||
error={error}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{(data || error) && !loading && (
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => { setExpanded(false); setData(null); setStatus(null); setError(null); }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Clear response
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Curl example (shown in collapsed footer) */}
|
||||
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-900/30 border-t border-gray-100 dark:border-gray-700/50">
|
||||
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
curl -H "Authorization: Bearer {'<token>'}" {typeof window !== 'undefined' ? window.location.origin : ''}{endpoint.path}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Component: API Docs Response Viewer
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Displays API response with syntax highlighting, status badge, and copy functionality.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface ResponseViewerProps {
|
||||
status: number | null;
|
||||
data: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function statusColor(status: number): string {
|
||||
if (status >= 200 && status < 300) return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300';
|
||||
if (status >= 400 && status < 500) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300';
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300';
|
||||
}
|
||||
|
||||
/** Tokenize JSON string into typed segments for React rendering */
|
||||
type JsonToken = { type: 'string' | 'number' | 'boolean' | 'null' | 'plain'; value: string };
|
||||
|
||||
function tokenizeJson(json: string): JsonToken[] {
|
||||
const tokens: JsonToken[] = [];
|
||||
const regex = /("(?:[^"\\]|\\.)*")|(\b\d+\.?\d*\b)|(\btrue\b|\bfalse\b)|(\bnull\b)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(json)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ type: 'plain', value: json.slice(lastIndex, match.index) });
|
||||
}
|
||||
if (match[1] !== undefined) tokens.push({ type: 'string', value: match[1] });
|
||||
else if (match[2] !== undefined) tokens.push({ type: 'number', value: match[2] });
|
||||
else if (match[3] !== undefined) tokens.push({ type: 'boolean', value: match[3] });
|
||||
else if (match[4] !== undefined) tokens.push({ type: 'null', value: match[4] });
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
if (lastIndex < json.length) {
|
||||
tokens.push({ type: 'plain', value: json.slice(lastIndex) });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const TOKEN_COLORS: Record<JsonToken['type'], string> = {
|
||||
string: 'text-emerald-400',
|
||||
number: 'text-blue-400',
|
||||
boolean: 'text-purple-400',
|
||||
null: 'text-purple-400',
|
||||
plain: 'text-gray-300',
|
||||
};
|
||||
|
||||
export function ResponseViewer({ status, data, error, loading }: ResponseViewerProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
if (!data) return [];
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(data), null, 2);
|
||||
return tokenizeJson(formatted);
|
||||
} catch {
|
||||
return [{ type: 'plain' as const, value: data }];
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(data), null, 2);
|
||||
await navigator.clipboard.writeText(formatted);
|
||||
} catch {
|
||||
await navigator.clipboard.writeText(data);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Sending request...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || status === null) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
Response
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ${statusColor(status)}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* JSON body */}
|
||||
<pre className="p-4 bg-[#0d1117] text-sm font-mono leading-relaxed overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<code>{tokens.map((t, i) => (
|
||||
<span key={i} className={TOKEN_COLORS[t.type]}>{t.value}</span>
|
||||
))}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Component: API Docs Token Input
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Token input field with toggle between custom API token and current session auth.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface TokenInputProps {
|
||||
token: string;
|
||||
onTokenChange: (token: string) => void;
|
||||
useSession: boolean;
|
||||
onUseSessionChange: (useSession: boolean) => void;
|
||||
}
|
||||
|
||||
export function TokenInput({
|
||||
token,
|
||||
onTokenChange,
|
||||
useSession,
|
||||
onUseSessionChange,
|
||||
}: TokenInputProps) {
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Authentication
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Choose how to authenticate your test requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session toggle */}
|
||||
<button
|
||||
onClick={() => onUseSessionChange(!useSession)}
|
||||
className={`
|
||||
relative inline-flex h-7 w-[140px] items-center rounded-full transition-colors duration-200
|
||||
${useSession
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
absolute inset-y-0.5 w-[68px] rounded-full bg-white dark:bg-gray-100 shadow-sm
|
||||
transition-transform duration-200 ease-in-out
|
||||
${useSession ? 'translate-x-[70px]' : 'translate-x-0.5'}
|
||||
`}
|
||||
/>
|
||||
<span
|
||||
className={`
|
||||
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
|
||||
${!useSession ? 'text-gray-900 dark:text-gray-900' : 'text-white/70'}
|
||||
`}
|
||||
>
|
||||
API Token
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
|
||||
${useSession ? 'text-gray-900 dark:text-gray-900' : 'text-gray-500 dark:text-gray-400'}
|
||||
`}
|
||||
>
|
||||
Session
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useSession ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Using your current browser session for authentication
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => onTokenChange(e.target.value)}
|
||||
placeholder="rmab_your_api_token_here"
|
||||
className="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900/50 px-4 py-2.5 pr-20 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{showToken ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ interface AudiobookDetailsModalProps {
|
||||
requestedByUsername?: string | null;
|
||||
hideRequestActions?: boolean;
|
||||
hasReportedIssue?: boolean;
|
||||
aiReason?: string | null;
|
||||
}
|
||||
|
||||
// Status helper
|
||||
@@ -74,6 +75,7 @@ export function AudiobookDetailsModal({
|
||||
requestedByUsername = null,
|
||||
hideRequestActions = false,
|
||||
hasReportedIssue = false,
|
||||
aiReason = null,
|
||||
}: AudiobookDetailsModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
@@ -455,6 +457,20 @@ export function AudiobookDetailsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Recommendation Reasoning */}
|
||||
{aiReason && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Why This Was Recommended
|
||||
</h3>
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
|
||||
{aiReason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Component: API Tokens Section (Profile Page)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
import type { ApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
export function ApiTokensSection() {
|
||||
const api = useApiTokens<ApiToken>({ basePath: '/api/user/api-tokens' });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
API Tokens
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Create personal API tokens for programmatic access to the API.{' '}
|
||||
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View API documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Error display */}
|
||||
{api.error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{api.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{api.createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{api.createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={api.handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{api.copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss token banner"
|
||||
onClick={api.dismissCreatedToken}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{api.showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={api.newTokenName}
|
||||
onChange={(e) => api.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' && api.handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={api.newTokenExpiry}
|
||||
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => api.handleCreate()}
|
||||
disabled={api.creating || !api.newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{api.creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={api.resetForm}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => api.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
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{api.loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : api.tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{api.tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{api.formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||
disabled={api.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"
|
||||
>
|
||||
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revoke confirmation dialog */}
|
||||
<ConfirmModal
|
||||
isOpen={api.confirmRevokeId !== null}
|
||||
title="Revoke API token"
|
||||
message={
|
||||
<>
|
||||
Are you sure you want to revoke{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</span>
|
||||
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmText="Revoke token"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={api.handleDeleteConfirmed}
|
||||
onClose={() => api.setConfirmRevokeId(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,9 @@ import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCancelRequest, useManualSearch } from '@/lib/hooks/useRequests';
|
||||
import { useCancelRequest } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
|
||||
@@ -43,11 +41,8 @@ interface RequestCardProps {
|
||||
|
||||
export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const { cancelRequest, isLoading } = useCancelRequest();
|
||||
const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch();
|
||||
const { squareCovers } = usePreferences();
|
||||
const { user } = useAuth();
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||
|
||||
const requestType = request.type || 'audiobook';
|
||||
@@ -57,10 +52,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
// Ebook requests don't support interactive search (Anna's Archive only)
|
||||
// Interactive search also requires the interactiveSearch permission
|
||||
const hasInteractiveSearchAccess = user?.role === 'admin' || user?.permissions?.interactiveSearch !== false;
|
||||
const canSearch = hasInteractiveSearchAccess && !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
||||
@@ -72,20 +63,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualSearch = async () => {
|
||||
try {
|
||||
await triggerManualSearch(request.id);
|
||||
// Request list will auto-refresh via SWR
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger manual search:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to trigger manual search');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setShowInteractiveSearch(true);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
@@ -255,27 +232,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
{/* Action Buttons */}
|
||||
{showActions && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canSearch && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleManualSearch}
|
||||
loading={isManualSearching}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Manual Search
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInteractiveSearch}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Interactive Search
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
@@ -293,17 +249,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Modal */}
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearch}
|
||||
onClose={() => setShowInteractiveSearch(false)}
|
||||
requestId={request.id}
|
||||
audiobook={{
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Audiobook Details Modal */}
|
||||
{request.audiobook.audibleAsin && (
|
||||
<AudiobookDetailsModal
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
|
||||
<div className="space-y-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">{message}</p>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{typeof message === 'string' ? <p>{message}</p> : message}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button onClick={onClose} variant="outline" disabled={isLoading}>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Component: API Token Constants
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Centralized API token constants used across authentication middleware and token routes.
|
||||
*/
|
||||
|
||||
/** Prefix prepended to all generated API tokens for identification */
|
||||
export const API_TOKEN_PREFIX = 'rmab_';
|
||||
|
||||
/** Number of random bytes used to generate the token's random portion */
|
||||
export const TOKEN_RANDOM_BYTES = 32;
|
||||
|
||||
/** Length of the token prefix stored in the database for display (first 12 chars: "rmab_" + 7 hex chars) */
|
||||
export const TOKEN_PREFIX_LENGTH = 12;
|
||||
|
||||
/** Maximum number of active (non-expired) API tokens a single user may hold */
|
||||
export const MAX_TOKENS_PER_USER = 25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoint allowlist — restricts which routes API tokens may access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape of an allowed endpoint entry */
|
||||
export interface AllowedEndpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Extended metadata used by the interactive API docs page */
|
||||
export interface EndpointDoc {
|
||||
method: string;
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
requiresAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that API tokens are permitted to call.
|
||||
* JWT-authenticated sessions are NOT restricted by this list.
|
||||
*/
|
||||
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
|
||||
{ method: 'GET', path: '/api/auth/me' },
|
||||
{ method: 'GET', path: '/api/requests' },
|
||||
{ method: 'GET', path: '/api/admin/metrics' },
|
||||
{ method: 'GET', path: '/api/admin/downloads/active' },
|
||||
{ method: 'GET', path: '/api/admin/requests/recent' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Full documentation metadata for each allowed endpoint.
|
||||
* Consumed by the /api-docs interactive page.
|
||||
*/
|
||||
export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/auth/me',
|
||||
title: 'Get current user',
|
||||
description:
|
||||
'Returns the authenticated user\'s profile information including username, role, and account details.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/requests',
|
||||
title: 'List requests',
|
||||
description:
|
||||
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/metrics',
|
||||
title: 'System metrics',
|
||||
description:
|
||||
'Returns system health metrics including request counts, download statistics, and library size.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/downloads/active',
|
||||
title: 'Active downloads',
|
||||
description:
|
||||
'Returns currently active downloads including progress, speed, and ETA.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/requests/recent',
|
||||
title: 'Recent requests',
|
||||
description:
|
||||
'Returns the most recent audiobook requests across all users.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check whether a given method + path is on the API token allowlist.
|
||||
* Method comparison is case-insensitive.
|
||||
*/
|
||||
export function isEndpointAllowed(method: string, path: string): boolean {
|
||||
const upperMethod = method.toUpperCase();
|
||||
return API_TOKEN_ALLOWED_ENDPOINTS.some(
|
||||
(ep) => ep.method === upperMethod && ep.path === path
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Component: Shared API Token Management Hook
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { ApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
/** Typed request body for creating an API token */
|
||||
export interface CreateTokenBody {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UseApiTokensConfig {
|
||||
/** Base API path, e.g. '/api/admin/api-tokens' or '/api/user/api-tokens' */
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export interface UseApiTokensReturn<T extends ApiToken = ApiToken> {
|
||||
tokens: T[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
newTokenName: string;
|
||||
setNewTokenName: (name: string) => void;
|
||||
newTokenExpiry: string;
|
||||
setNewTokenExpiry: (expiry: string) => void;
|
||||
showCreateForm: boolean;
|
||||
setShowCreateForm: (show: boolean) => void;
|
||||
createdToken: string | null;
|
||||
copied: boolean;
|
||||
deletingId: string | null;
|
||||
confirmRevokeId: string | null;
|
||||
setConfirmRevokeId: (id: string | null) => void;
|
||||
fetchTokens: () => Promise<void>;
|
||||
handleCreate: (extraBody?: Partial<CreateTokenBody>) => Promise<boolean>;
|
||||
handleDeleteConfirmed: () => Promise<void>;
|
||||
handleCopy: () => Promise<void>;
|
||||
dismissCreatedToken: () => void;
|
||||
resetForm: () => void;
|
||||
formatDate: (dateStr: string | null) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook for API token CRUD operations.
|
||||
* Used by both the admin ApiTab and the user ApiTokensSection.
|
||||
*/
|
||||
export function useApiTokens<T extends ApiToken = ApiToken>(
|
||||
config: UseApiTokensConfig
|
||||
): UseApiTokensReturn<T> {
|
||||
const [tokens, setTokens] = useState<T[]>([]);
|
||||
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<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(config.basePath);
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to load API tokens';
|
||||
try {
|
||||
const data = await response.json();
|
||||
message = data.error || message;
|
||||
} catch {
|
||||
// Keep default message when response body is not JSON
|
||||
}
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTokens(data.tokens);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Failed to load API tokens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [config.basePath]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTokens();
|
||||
}, [fetchTokens]);
|
||||
|
||||
const computeExpiresAt = (): string | null => {
|
||||
if (newTokenExpiry === 'never') return null;
|
||||
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;
|
||||
}
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const handleCreate = async (extraBody?: Partial<CreateTokenBody>) => {
|
||||
if (!newTokenName.trim()) {
|
||||
setError('Token name is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const body: CreateTokenBody = {
|
||||
name: newTokenName.trim(),
|
||||
expiresAt: computeExpiresAt(),
|
||||
...extraBody,
|
||||
};
|
||||
|
||||
const response = await fetchWithAuth(config.basePath, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCreatedToken(data.fullToken);
|
||||
setNewTokenName('');
|
||||
setNewTokenExpiry('never');
|
||||
setShowCreateForm(false);
|
||||
await fetchTokens();
|
||||
return true;
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to create token');
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to create token');
|
||||
return false;
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmed = async () => {
|
||||
const id = confirmRevokeId;
|
||||
if (!id) return;
|
||||
|
||||
setConfirmRevokeId(null);
|
||||
setDeletingId(id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${config.basePath}/${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) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setError('Failed to copy to clipboard. Please select and copy the token manually.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dismissCreatedToken = () => setCreatedToken(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setShowCreateForm(false);
|
||||
setNewTokenName('');
|
||||
setNewTokenExpiry('never');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null): string => {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
tokens,
|
||||
loading,
|
||||
creating,
|
||||
error,
|
||||
newTokenName,
|
||||
setNewTokenName,
|
||||
newTokenExpiry,
|
||||
setNewTokenExpiry,
|
||||
showCreateForm,
|
||||
setShowCreateForm,
|
||||
createdToken,
|
||||
copied,
|
||||
deletingId,
|
||||
confirmRevokeId,
|
||||
setConfirmRevokeId,
|
||||
fetchTokens,
|
||||
handleCreate,
|
||||
handleDeleteConfirmed,
|
||||
handleCopy,
|
||||
dismissCreatedToken,
|
||||
resetForm,
|
||||
formatDate,
|
||||
};
|
||||
}
|
||||
+107
-3
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
|
||||
|
||||
const logger = RMABLogger.create('Auth');
|
||||
|
||||
@@ -32,9 +34,70 @@ 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 | 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,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken) return null;
|
||||
|
||||
// Check expiration
|
||||
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
||||
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
|
||||
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 },
|
||||
data: { lastUsedAt: new Date() },
|
||||
}).catch((err) => {
|
||||
logger.debug('Failed to update API token lastUsedAt', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
tokenId: apiToken.id,
|
||||
});
|
||||
});
|
||||
|
||||
// Use the token's target user (userId), not the creator (createdById)
|
||||
return {
|
||||
sub: 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 +116,43 @@ 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 }
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce endpoint allowlist for API token auth
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const method = request.method;
|
||||
if (!isEndpointAllowed(method, pathname)) {
|
||||
logger.warn('API token used on restricted endpoint', {
|
||||
method,
|
||||
path: pathname,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden',
|
||||
message: 'This endpoint is not available via API token authentication',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = { ...apiUser, id: apiUser.sub };
|
||||
return handler(authenticatedRequest);
|
||||
}
|
||||
|
||||
// Fall back to JWT verification
|
||||
const payload = verifyAccessToken(token);
|
||||
|
||||
if (!payload) {
|
||||
@@ -69,9 +169,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(
|
||||
{
|
||||
@@ -86,7 +190,7 @@ export async function requireAuth(
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = {
|
||||
...payload,
|
||||
id: user.id,
|
||||
id: payload.sub,
|
||||
};
|
||||
|
||||
return handler(authenticatedRequest);
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
|
||||
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<string, any> = {};
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<AuthResult> {
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Component: API Token Type Definitions
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
/** Base API token as returned by user-facing endpoints */
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
role: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Extended API token with cross-user fields, returned by admin endpoints */
|
||||
export interface AdminApiToken extends ApiToken {
|
||||
createdBy: string;
|
||||
createdById: string;
|
||||
tokenUser: string;
|
||||
tokenUserId: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Component: API Token Generation Utility
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { API_TOKEN_PREFIX, TOKEN_RANDOM_BYTES, TOKEN_PREFIX_LENGTH } from '../constants/api-tokens';
|
||||
|
||||
interface GeneratedToken {
|
||||
/** The full token string to return to the user (shown only once) */
|
||||
fullToken: string;
|
||||
/** SHA-256 hash of the full token (stored in database) */
|
||||
tokenHash: string;
|
||||
/** Display prefix for identification (first 12 chars) */
|
||||
tokenPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API token with its hash and display prefix.
|
||||
* The full token is: API_TOKEN_PREFIX + random hex string.
|
||||
* Only the hash is stored; the full token is returned once at creation.
|
||||
*/
|
||||
export function generateApiToken(): GeneratedToken {
|
||||
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, TOKEN_PREFIX_LENGTH);
|
||||
|
||||
return { fullToken, tokenHash, tokenPrefix };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Component: API Token Rate Limiting
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
|
||||
* to prevent unbounded memory growth.
|
||||
*/
|
||||
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** Number of checkRateLimit calls since the last full sweep */
|
||||
let checkCount = 0;
|
||||
|
||||
/** How often (in calls) to perform a full sweep of expired buckets */
|
||||
const SWEEP_INTERVAL = 100;
|
||||
|
||||
/**
|
||||
* Sweep the entire bucket map and delete all expired entries.
|
||||
* Called automatically every SWEEP_INTERVAL checks.
|
||||
*/
|
||||
function sweepExpiredBuckets(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (now >= bucket.resetAt) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||
checkCount += 1;
|
||||
if (checkCount >= SWEEP_INTERVAL) {
|
||||
checkCount = 0;
|
||||
sweepExpiredBuckets();
|
||||
}
|
||||
|
||||
const current = buckets.get(key);
|
||||
|
||||
// Lazy eviction: if the bucket is expired, delete it and start fresh
|
||||
if (!current || now >= current.resetAt) {
|
||||
if (current) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** Reset all buckets and the sweep counter. For testing only. */
|
||||
export function _resetBuckets(): void {
|
||||
buckets.clear();
|
||||
checkCount = 0;
|
||||
}
|
||||
|
||||
/** Get the current number of tracked buckets. For testing only. */
|
||||
export function _getBucketCount(): number {
|
||||
return buckets.size;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Component: Client-side URL Utilities
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the current instance origin URL.
|
||||
* Returns window.location.origin on the client, or a placeholder on the server.
|
||||
*/
|
||||
export function getInstanceUrl(): string {
|
||||
return typeof window !== 'undefined' ? window.location.origin : 'https://your-instance';
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Component: Admin API Tokens Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
// Valid UUIDs for testing
|
||||
const ADMIN_ID = '11111111-1111-1111-1111-111111111111';
|
||||
const USER_ID = '22222222-2222-2222-2222-222222222222';
|
||||
const ADMIN2_ID = '33333333-3333-3333-3333-333333333333';
|
||||
const NONEXISTENT_ID = '99999999-9999-9999-9999-999999999999';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const checkApiTokenCreateRateLimitMock = vi.hoisted(() => vi.fn());
|
||||
const generateApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({
|
||||
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api-token', () => ({
|
||||
generateApiToken: generateApiTokenMock,
|
||||
}));
|
||||
|
||||
describe('Admin API tokens routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: ADMIN_ID, username: 'admin', role: 'admin' },
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
checkApiTokenCreateRateLimitMock.mockReturnValue({ allowed: true });
|
||||
generateApiTokenMock.mockReturnValue({
|
||||
fullToken: 'rmab_test_full_token',
|
||||
tokenHash: 'hashed_token',
|
||||
tokenPrefix: 'rmab_test',
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/api-tokens', () => {
|
||||
it('creates token for self with own role when no userId specified', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({ name: 'Test Token' });
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: ADMIN_ID,
|
||||
role: 'admin',
|
||||
plexUsername: 'admin',
|
||||
});
|
||||
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||
id: 'token-1',
|
||||
name: 'Test Token',
|
||||
tokenPrefix: 'rmab_test',
|
||||
role: 'admin',
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.token.role).toBe('admin');
|
||||
expect(payload.fullToken).toBe('rmab_test_full_token');
|
||||
});
|
||||
|
||||
it('creates token for another user with their role', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Token for User',
|
||||
userId: USER_ID,
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: USER_ID,
|
||||
role: 'user',
|
||||
plexUsername: 'regularuser',
|
||||
});
|
||||
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||
id: 'token-2',
|
||||
name: 'Token for User',
|
||||
tokenPrefix: 'rmab_test',
|
||||
role: 'user',
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.token.role).toBe('user');
|
||||
});
|
||||
|
||||
it('rejects role override when role differs from target user role', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Escalation Attempt',
|
||||
userId: USER_ID,
|
||||
role: 'admin', // Trying to give admin role to a regular user
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: USER_ID,
|
||||
role: 'user', // Target user is actually a regular user
|
||||
plexUsername: 'regularuser',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("must match target user's role");
|
||||
});
|
||||
|
||||
it('rejects role downgrade when role differs from target user role', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Downgrade Attempt',
|
||||
userId: ADMIN2_ID,
|
||||
role: 'user', // Trying to give user role to an admin
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: ADMIN2_ID,
|
||||
role: 'admin', // Target user is actually an admin
|
||||
plexUsername: 'otheradmin',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("must match target user's role");
|
||||
});
|
||||
|
||||
it('accepts role when it matches target user role', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Matching Role',
|
||||
userId: USER_ID,
|
||||
role: 'user', // Explicitly specifying role that matches
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: USER_ID,
|
||||
role: 'user',
|
||||
plexUsername: 'regularuser',
|
||||
});
|
||||
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||
id: 'token-3',
|
||||
name: 'Matching Role',
|
||||
tokenPrefix: 'rmab_test',
|
||||
role: 'user',
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
|
||||
it('returns 404 when target user does not exist', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Token for Ghost',
|
||||
userId: NONEXISTENT_ID,
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Target user not found');
|
||||
});
|
||||
|
||||
it('returns 429 when rate limited', async () => {
|
||||
checkApiTokenCreateRateLimitMock.mockReturnValueOnce({
|
||||
allowed: false,
|
||||
retryAfterSeconds: 60,
|
||||
});
|
||||
|
||||
authRequest.json.mockResolvedValueOnce({ name: 'Rate Limited Token' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get('Retry-After')).toBe('60');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -216,7 +216,7 @@ describe('BookDatePage', () => {
|
||||
await screen.findByText(/Could not load recommendations/);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
|
||||
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/settings');
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/admin/settings');
|
||||
});
|
||||
|
||||
it('shows empty state and triggers recommendation generation', async () => {
|
||||
|
||||
@@ -10,23 +10,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const cancelRequestMock = vi.hoisted(() => vi.fn());
|
||||
const manualSearchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useCancelRequest: () => ({ cancelRequest: cancelRequestMock, isLoading: false }),
|
||||
useManualSearch: () => ({ triggerManualSearch: manualSearchMock, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||
InteractiveTorrentSearchModal: ({
|
||||
isOpen,
|
||||
requestId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
requestId?: string;
|
||||
}) => (
|
||||
<div data-testid="interactive-modal" data-open={String(isOpen)} data-request-id={requestId} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
@@ -40,7 +26,7 @@ vi.mock('@/contexts/PreferencesContext', () => ({
|
||||
|
||||
vi.mock('@/contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 'user-1', role: 'user', permissions: { interactiveSearch: true } },
|
||||
user: { id: 'user-1', role: 'user' },
|
||||
accessToken: 'test-token',
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
@@ -66,7 +52,6 @@ const baseRequest = {
|
||||
describe('RequestCard', () => {
|
||||
beforeEach(() => {
|
||||
cancelRequestMock.mockReset();
|
||||
manualSearchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -109,29 +94,29 @@ describe('RequestCard', () => {
|
||||
expect(await screen.findByText('Failure details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers manual search, interactive search, and cancel actions', async () => {
|
||||
it('triggers cancel action', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
manualSearchMock.mockResolvedValueOnce(undefined);
|
||||
cancelRequestMock.mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RequestCard request={baseRequest} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manual Search' }));
|
||||
await waitFor(() => {
|
||||
expect(manualSearchMock).toHaveBeenCalledWith('req-1');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Interactive Search' }));
|
||||
expect(screen.getByTestId('interactive-modal')).toHaveAttribute('data-open', 'true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => {
|
||||
expect(cancelRequestMock).toHaveBeenCalledWith('req-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show manual search or interactive search buttons', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
render(<RequestCard request={baseRequest} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Manual Search' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Interactive Search' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows setup indicator when progress is zero', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
@@ -153,25 +138,9 @@ describe('RequestCard', () => {
|
||||
|
||||
render(<RequestCard request={baseRequest} showActions={false} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Manual Search' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
|
||||
});
|
||||
|
||||
it('alerts when manual search fails', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
manualSearchMock.mockRejectedValueOnce(new Error('Search failed'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
render(<RequestCard request={baseRequest} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manual Search' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('Search failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not cancel when confirmation is declined', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export const createPrismaMock = () => ({
|
||||
goodreadsShelf: createModelMock(),
|
||||
bookMapping: createModelMock(),
|
||||
hardcoverShelf: createModelMock(),
|
||||
apiToken: createModelMock(),
|
||||
work: createModelMock(),
|
||||
workAsin: createModelMock(),
|
||||
watchedSeries: createModelMock(),
|
||||
|
||||
@@ -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();
|
||||
@@ -18,7 +20,9 @@ vi.mock('@/lib/utils/jwt', () => ({
|
||||
verifyAccessToken: verifyAccessTokenMock,
|
||||
}));
|
||||
|
||||
const makeRequest = (authHeader?: string) => ({
|
||||
const makeRequest = (authHeader?: string, pathname = '/api/requests', method = 'GET') => ({
|
||||
method,
|
||||
nextUrl: { pathname },
|
||||
headers: {
|
||||
get: (key: string) => {
|
||||
if (key.toLowerCase() === 'authorization') {
|
||||
@@ -29,6 +33,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 +168,189 @@ 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 on allowed endpoints', 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}`, '/api/requests', 'GET') as any,
|
||||
handler
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(payload.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('blocks API tokens on endpoints not in the allowlist', async () => {
|
||||
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
tokenHash: testTokenHash,
|
||||
role: 'admin',
|
||||
expiresAt: null,
|
||||
tokenUser: {
|
||||
id: 'user-1',
|
||||
plexUsername: 'activeuser',
|
||||
role: 'admin',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
prismaMock.apiToken.update.mockResolvedValue({});
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const handler = vi.fn();
|
||||
const response = await requireAuth(
|
||||
makeRequest(`Bearer ${testToken}`, '/api/admin/settings', 'GET') as any,
|
||||
handler
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.message).toMatch(/not available via API token/i);
|
||||
});
|
||||
|
||||
it('allows API tokens on all 5 permitted endpoints', async () => {
|
||||
const allowedPaths = [
|
||||
'/api/auth/me',
|
||||
'/api/requests',
|
||||
'/api/admin/metrics',
|
||||
'/api/admin/downloads/active',
|
||||
'/api/admin/requests/recent',
|
||||
];
|
||||
|
||||
for (const path of allowedPaths) {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
tokenHash: testTokenHash,
|
||||
role: 'admin',
|
||||
expiresAt: null,
|
||||
tokenUser: {
|
||||
id: 'user-1',
|
||||
plexUsername: 'activeuser',
|
||||
role: 'admin',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
prismaMock.apiToken.update.mockResolvedValue({});
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const handler = vi.fn(async () => NextResponse.json({ ok: true }));
|
||||
const response = await requireAuth(
|
||||
makeRequest(`Bearer ${testToken}`, path, 'GET') as any,
|
||||
handler
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not restrict JWT-authenticated users to the allowlist', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'user',
|
||||
role: 'admin',
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' });
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const handler = vi.fn(async () => NextResponse.json({ ok: true }));
|
||||
// Use a non-allowlisted endpoint — JWT should still work
|
||||
const response = await requireAuth(
|
||||
makeRequest('Bearer jwttoken', '/api/admin/settings', 'POST') as any,
|
||||
handler
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns current user from token', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Component: API Token Rate Limit Tests
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
checkApiTokenCreateRateLimit,
|
||||
checkApiTokenRevokeRateLimit,
|
||||
_resetBuckets,
|
||||
_getBucketCount,
|
||||
} from '@/lib/utils/apiTokenRateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
|
||||
describe('API Token Rate Limiting', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
_resetBuckets();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
_resetBuckets();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lazy eviction', () => {
|
||||
it('deletes expired buckets when they are next accessed', () => {
|
||||
const actorId = 'user-evict-1';
|
||||
|
||||
// Create a bucket
|
||||
checkApiTokenCreateRateLimit(actorId);
|
||||
expect(_getBucketCount()).toBe(1);
|
||||
|
||||
// Expire the window
|
||||
vi.advanceTimersByTime(61 * 1000);
|
||||
|
||||
// Accessing the same key should evict the old bucket and create a fresh one
|
||||
checkApiTokenCreateRateLimit(actorId);
|
||||
// Should still be 1 (old one deleted, new one created)
|
||||
expect(_getBucketCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('does not delete buckets that are still active', () => {
|
||||
// Create buckets for two actors
|
||||
checkApiTokenCreateRateLimit('actor-a');
|
||||
checkApiTokenCreateRateLimit('actor-b');
|
||||
expect(_getBucketCount()).toBe(2);
|
||||
|
||||
// Advance partially (not past the 60s window)
|
||||
vi.advanceTimersByTime(30 * 1000);
|
||||
|
||||
// Both should still be there
|
||||
checkApiTokenCreateRateLimit('actor-a');
|
||||
expect(_getBucketCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('periodic sweep', () => {
|
||||
it('sweeps all expired buckets every 100 checks', () => {
|
||||
// Create 10 unique actor buckets
|
||||
for (let i = 0; i < 10; i++) {
|
||||
checkApiTokenCreateRateLimit(`sweep-actor-${i}`);
|
||||
}
|
||||
expect(_getBucketCount()).toBe(10);
|
||||
|
||||
// Expire all windows
|
||||
vi.advanceTimersByTime(61 * 1000);
|
||||
|
||||
// Add some fresh buckets that should NOT be swept
|
||||
checkApiTokenCreateRateLimit('sweep-fresh-1');
|
||||
checkApiTokenCreateRateLimit('sweep-fresh-2');
|
||||
|
||||
// We've done 10 + 2 = 12 calls so far. Need 100 total to trigger sweep.
|
||||
// Do 88 more calls with unique actors to reach 100
|
||||
for (let i = 0; i < 88; i++) {
|
||||
checkApiTokenCreateRateLimit(`sweep-filler-${i}`);
|
||||
}
|
||||
|
||||
// After the 100th call, the sweep should have removed the 10 expired buckets.
|
||||
// Remaining: 2 fresh + 88 filler = 90
|
||||
expect(_getBucketCount()).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_resetBuckets', () => {
|
||||
it('clears all buckets', () => {
|
||||
checkApiTokenCreateRateLimit('reset-1');
|
||||
checkApiTokenCreateRateLimit('reset-2');
|
||||
expect(_getBucketCount()).toBeGreaterThan(0);
|
||||
|
||||
_resetBuckets();
|
||||
expect(_getBucketCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAX_TOKENS_PER_USER constant', () => {
|
||||
it('is set to 25', () => {
|
||||
expect(MAX_TOKENS_PER_USER).toBe(25);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user