diff --git a/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql b/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql new file mode 100644 index 0000000..2950e36 --- /dev/null +++ b/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "api_tokens" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "token_prefix" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + "created_by_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "last_used_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "api_tokens_created_by_id_idx" ON "api_tokens"("created_by_id"); + +-- CreateIndex +CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id"); + +-- AddForeignKey +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2decb53..174d882 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,8 @@ model User { goodreadsShelves GoodreadsShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") + createdApiTokens ApiToken[] @relation("CreatedApiTokens") + apiTokens ApiToken[] @relation("UserApiTokens") watchedSeries WatchedSeries[] watchedAuthors WatchedAuthor[] @@ -498,6 +500,34 @@ model ReportedIssue { // Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache // ============================================================================ +// ============================================================================ +// API TOKEN TABLE +// Static API tokens for programmatic access (alternative to JWT) +// Documentation: documentation/backend/services/api-tokens.md +// ============================================================================ + +model ApiToken { + id String @id @default(uuid()) + name String // User-friendly label (e.g., "Home Assistant", "Webhook") + tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext) + tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2") + role String @default("user") // Token role: 'admin' or 'user' + createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens) + userId String @map("user_id") // The user identity this token acts as + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") // null = never expires + createdAt DateTime @default(now()) @map("created_at") + + // Relations + createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade) + tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade) + + @@index([tokenHash]) + @@index([createdById]) + @@index([userId]) + @@map("api_tokens") +} + model GoodreadsShelf { id String @id @default(uuid()) userId String @map("user_id") diff --git a/src/app/admin/components/ConfirmDialog.tsx b/src/app/admin/components/ConfirmDialog.tsx index ef71e08..6e1e10d 100644 --- a/src/app/admin/components/ConfirmDialog.tsx +++ b/src/app/admin/components/ConfirmDialog.tsx @@ -2,12 +2,13 @@ * Component: Confirm Dialog * Documentation: documentation/frontend/components.md * - * Reusable confirmation dialog for destructive actions + * Reusable confirmation dialog for destructive actions. + * Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA. */ 'use client'; -import { Fragment } from 'react'; +import React, { useEffect, useRef } from 'react'; export interface ConfirmDialogProps { isOpen: boolean; @@ -30,99 +31,177 @@ export function ConfirmDialog({ onConfirm, onCancel, }: ConfirmDialogProps) { + const cancelRef = useRef(null); + const confirmRef = useRef(null); + const dialogRef = useRef(null); + + // Focus the cancel button on open (safer default for destructive dialogs) + useEffect(() => { + if (isOpen) { + // Small delay to let animation start before stealing focus + const t = setTimeout(() => cancelRef.current?.focus(), 50); + return () => clearTimeout(t); + } + }, [isOpen]); + + // Escape to close + focus trap + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + return; + } + + // Focus trap: tab cycles only within dialog + if (e.key === 'Tab') { + const focusable = dialogRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (!focusable || focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onCancel]); + + // Prevent body scroll while open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + } + }, [isOpen]); + if (!isOpen) return null; - const confirmButtonClasses = - confirmVariant === 'danger' - ? 'bg-red-600 hover:bg-red-700 text-white' - : 'bg-blue-600 hover:bg-blue-700 text-white'; + const isDestructive = confirmVariant === 'danger'; return ( -
+
{/* Backdrop */} )} + + {/* API Tokens */} +
); diff --git a/src/components/api-docs/EndpointCard.tsx b/src/components/api-docs/EndpointCard.tsx new file mode 100644 index 0000000..a521dcd --- /dev/null +++ b/src/components/api-docs/EndpointCard.tsx @@ -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 = { + 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(null); + const [data, setData] = useState(null); + const [error, setError] = useState(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 ( +
+ {/* Card header */} +
+
+
+
+ + {endpoint.method} + + + {endpoint.path} + + {endpoint.requiresAdmin && ( + + Admin + + )} +
+

+ {endpoint.title} +

+

+ {endpoint.description} +

+
+ + +
+ + {/* Expandable response area */} +
+ + + {(data || error) && !loading && ( +
+ +
+ )} +
+
+ + {/* Curl example (shown in collapsed footer) */} +
+ + curl -H "Authorization: Bearer {''}" {typeof window !== 'undefined' ? window.location.origin : ''}{endpoint.path} + +
+
+ ); +} diff --git a/src/components/api-docs/ResponseViewer.tsx b/src/components/api-docs/ResponseViewer.tsx new file mode 100644 index 0000000..acb12a8 --- /dev/null +++ b/src/components/api-docs/ResponseViewer.tsx @@ -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 = { + 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 ( +
+
+
+ Sending request... +
+
+ ); + } + + if (error) { + return ( +
+
+ + + + {error} +
+
+ ); + } + + if (!data || status === null) return null; + + return ( +
+ {/* Header bar */} +
+
+ + Response + + + {status} + +
+ +
+ + {/* JSON body */} +
+        {tokens.map((t, i) => (
+          {t.value}
+        ))}
+      
+
+ ); +} diff --git a/src/components/api-docs/TokenInput.tsx b/src/components/api-docs/TokenInput.tsx new file mode 100644 index 0000000..64eb5af --- /dev/null +++ b/src/components/api-docs/TokenInput.tsx @@ -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 ( +
+
+
+

+ Authentication +

+

+ Choose how to authenticate your test requests +

+
+ + {/* Session toggle */} + +
+ + {useSession ? ( +
+ + + + + Using your current browser session for authentication + +
+ ) : ( +
+ 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" + /> + +
+ )} +
+ ); +} diff --git a/src/components/profile/ApiTokensSection.tsx b/src/components/profile/ApiTokensSection.tsx new file mode 100644 index 0000000..b3586ee --- /dev/null +++ b/src/components/profile/ApiTokensSection.tsx @@ -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({ basePath: '/api/user/api-tokens' }); + + return ( +
+
+
+

+ API Tokens +

+

+ Create personal API tokens for programmatic access to the API.{' '} + + View API documentation + +

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

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

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

Create New Token

+
+
+ + 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()} + /> +
+
+ + +
+
+
+ + +
+
+ ) : ( + + )} + + {/* Token list */} + {api.loading ? ( +
+
+
+ ) : api.tokens.length === 0 ? ( +
+ + + +

No API tokens yet

+

Create a token to enable programmatic API access

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

Usage

+

+ Include the token in the Authorization header: +

+
+{`curl -H "Authorization: Bearer rmab_your_token_here" \\
+  ${getInstanceUrl()}/api/requests`}
+            
+
+
+
+ + {/* Revoke confirmation dialog */} + + Are you sure you want to revoke{' '} + + “{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}” + + ? Any integrations using this token will immediately lose access. This cannot be undone. + + } + confirmText="Revoke token" + cancelText="Cancel" + variant="danger" + onConfirm={api.handleDeleteConfirmed} + onClose={() => api.setConfirmRevokeId(null)} + /> +
+ ); +} diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 431b48d..19ea0ef 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -14,7 +14,7 @@ interface ConfirmModalProps { onClose: () => void; onConfirm: () => void; title: string; - message: string; + message: string | React.ReactNode; confirmText?: string; cancelText?: string; isLoading?: boolean; @@ -35,7 +35,9 @@ export function ConfirmModal({ return (
-

{message}

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

{message}

: message} +