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

Create New Token

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

No API tokens yet

+

Create a token to enable programmatic API access

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

Usage

+

+ Include the token in the Authorization header: +

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

+ API Tokens +

+

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

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

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

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

Create New Token

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

No API tokens yet

+

Create a token to enable programmatic API access

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

Usage

+

+ Include the token in the Authorization header: +

+
+{`curl -H "Authorization: Bearer rmab_your_token_here" \\
+  ${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
+            
+
+
+
+
+ ); +} diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index ce44d33..b42940d 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -4,12 +4,15 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; import { verifyAccessToken, TokenPayload } from '../utils/jwt'; import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; const logger = RMABLogger.create('Auth'); +const API_TOKEN_PREFIX = 'rmab_'; + export interface AuthenticatedRequest extends NextRequest { user?: TokenPayload & { id: string }; } @@ -32,9 +35,47 @@ function extractToken(request: NextRequest): string | null { return parts[1]; } +/** + * Authenticate via static API token (rmab_ prefix). + * Returns a synthetic TokenPayload if valid, null otherwise. + * Updates lastUsedAt asynchronously. + */ +async function authenticateApiToken(token: string): Promise<(TokenPayload & { id: string }) | null> { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + const apiToken = await prisma.apiToken.findUnique({ + where: { tokenHash }, + include: { tokenUser: { select: { id: true, plexId: true, plexUsername: true, role: true } } }, + }); + + if (!apiToken) return null; + + // Check expiration + if (apiToken.expiresAt && apiToken.expiresAt < new Date()) { + logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix }); + return null; + } + + // Update lastUsedAt (fire-and-forget) + prisma.apiToken.update({ + where: { id: apiToken.id }, + data: { lastUsedAt: new Date() }, + }).catch(() => {}); + + // Use the token's target user (userId), not the creator (createdById) + const user = apiToken.tokenUser; + return { + sub: user.id, + id: user.id, + plexId: user.plexId, + username: user.plexUsername, + role: apiToken.role, + }; +} + /** * Middleware: Require authentication - * Verifies JWT token and adds user to request + * Verifies JWT token or static API token and adds user to request */ export async function requireAuth( request: NextRequest, @@ -53,6 +94,26 @@ export async function requireAuth( ); } + // Check if this is a static API token + if (token.startsWith(API_TOKEN_PREFIX)) { + const apiUser = await authenticateApiToken(token); + if (!apiUser) { + logger.error('API token authentication failed'); + return NextResponse.json( + { + error: 'Unauthorized', + message: 'Invalid or expired API token', + }, + { status: 401 } + ); + } + + const authenticatedRequest = request as AuthenticatedRequest; + authenticatedRequest.user = apiUser; + return handler(authenticatedRequest); + } + + // Fall back to JWT verification const payload = verifyAccessToken(token); if (!payload) { From 04b6a2c135ff7d78a5e792be42a0d869aace381c Mon Sep 17 00:00:00 2001 From: Michael Borohovski Date: Tue, 3 Mar 2026 15:16:03 -0800 Subject: [PATCH 02/10] Harden API token auth for deleted users and add route rate limiting --- src/app/api/admin/api-tokens/[id]/route.ts | 14 ++++++++ src/app/api/admin/api-tokens/route.ts | 14 ++++++++ src/app/api/user/api-tokens/[id]/route.ts | 14 ++++++++ src/app/api/user/api-tokens/route.ts | 14 ++++++++ src/lib/middleware/auth.ts | 29 +++++++++++++-- src/lib/utils/apiTokenRateLimit.ts | 42 ++++++++++++++++++++++ 6 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/lib/utils/apiTokenRateLimit.ts diff --git a/src/app/api/admin/api-tokens/[id]/route.ts b/src/app/api/admin/api-tokens/[id]/route.ts index cfe7b38..e0b0e7a 100644 --- a/src/app/api/admin/api-tokens/[id]/route.ts +++ b/src/app/api/admin/api-tokens/[id]/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; const logger = RMABLogger.create('API.Admin.ApiTokens'); @@ -21,6 +22,19 @@ export async function DELETE( return requireAuth(request, (req: AuthenticatedRequest) => requireAdmin(req, async () => { try { + const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token revoke attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const { id } = await params; const token = await prisma.apiToken.findUnique({ where: { id } }); diff --git a/src/app/api/admin/api-tokens/route.ts b/src/app/api/admin/api-tokens/route.ts index 34bf58f..b28c826 100644 --- a/src/app/api/admin/api-tokens/route.ts +++ b/src/app/api/admin/api-tokens/route.ts @@ -8,6 +8,7 @@ import crypto from 'crypto'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; import { z } from 'zod'; const logger = RMABLogger.create('API.Admin.ApiTokens'); @@ -74,6 +75,19 @@ export async function POST(request: NextRequest) { return requireAuth(request, (req: AuthenticatedRequest) => requireAdmin(req, async () => { try { + const rateLimit = checkApiTokenCreateRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token create attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const body = await req.json(); const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body); diff --git a/src/app/api/user/api-tokens/[id]/route.ts b/src/app/api/user/api-tokens/[id]/route.ts index 771113c..4169218 100644 --- a/src/app/api/user/api-tokens/[id]/route.ts +++ b/src/app/api/user/api-tokens/[id]/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; const logger = RMABLogger.create('API.User.ApiTokens'); @@ -20,6 +21,19 @@ export async function DELETE( ) { return requireAuth(request, async (req: AuthenticatedRequest) => { try { + const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token revoke attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const { id } = await params; const token = await prisma.apiToken.findUnique({ where: { id } }); diff --git a/src/app/api/user/api-tokens/route.ts b/src/app/api/user/api-tokens/route.ts index 8ffa8af..234b54f 100644 --- a/src/app/api/user/api-tokens/route.ts +++ b/src/app/api/user/api-tokens/route.ts @@ -8,6 +8,7 @@ import crypto from 'crypto'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; import { z } from 'zod'; const logger = RMABLogger.create('API.User.ApiTokens'); @@ -57,6 +58,19 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { try { + const rateLimit = checkApiTokenCreateRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token create attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const body = await req.json(); const { name, expiresAt } = CreateTokenSchema.parse(body); diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index b42940d..c1e7f98 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -45,7 +45,17 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id const apiToken = await prisma.apiToken.findUnique({ where: { tokenHash }, - include: { tokenUser: { select: { id: true, plexId: true, plexUsername: true, role: true } } }, + include: { + tokenUser: { + select: { + id: true, + plexId: true, + plexUsername: true, + role: true, + deletedAt: true, + }, + }, + }, }); if (!apiToken) return null; @@ -56,6 +66,16 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id return null; } + // Reject tokens for soft-deleted users + const user = apiToken.tokenUser; + if (!user || user.deletedAt) { + logger.warn('API token used by deleted or missing user', { + tokenPrefix: apiToken.tokenPrefix, + userId: user?.id, + }); + return null; + } + // Update lastUsedAt (fire-and-forget) prisma.apiToken.update({ where: { id: apiToken.id }, @@ -63,7 +83,6 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id }).catch(() => {}); // Use the token's target user (userId), not the creator (createdById) - const user = apiToken.tokenUser; return { sub: user.id, id: user.id, @@ -130,9 +149,13 @@ export async function requireAuth( // Verify user still exists in database const user = await prisma.user.findUnique({ where: { id: payload.sub }, + select: { + id: true, + deletedAt: true, + }, }); - if (!user) { + if (!user || user.deletedAt) { logger.error('User not found in database', { userId: payload.sub }); return NextResponse.json( { diff --git a/src/lib/utils/apiTokenRateLimit.ts b/src/lib/utils/apiTokenRateLimit.ts new file mode 100644 index 0000000..9d01e2c --- /dev/null +++ b/src/lib/utils/apiTokenRateLimit.ts @@ -0,0 +1,42 @@ +type Bucket = { + count: number; + resetAt: number; +}; + +type RateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + +const buckets = new Map(); + +function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { + const now = Date.now(); + const current = buckets.get(key); + + if (!current || now >= current.resetAt) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) }; + } + + if (current.count >= maxRequests) { + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; + } + + current.count += 1; + return { + allowed: true, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; +} + +export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult { + return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000); +} + +export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult { + return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000); +} From f0b2476b8767f9ccff5ae470441364a7456db471 Mon Sep 17 00:00:00 2001 From: Michael Borohovski Date: Tue, 3 Mar 2026 15:45:07 -0800 Subject: [PATCH 03/10] Add tests for security hardening: deleted user auth rejection, rate limiting --- tests/helpers/prisma.ts | 1 + tests/middleware/auth.middleware.test.ts | 99 ++++++++++++++++++ tests/utils/apiTokenRateLimit.test.ts | 122 +++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 tests/utils/apiTokenRateLimit.test.ts diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index fc551c1..c816cc5 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -47,6 +47,7 @@ export const createPrismaMock = () => ({ bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), goodreadsBookMapping: createModelMock(), + apiToken: createModelMock(), $queryRaw: vi.fn(), $disconnect: vi.fn(), }); diff --git a/tests/middleware/auth.middleware.test.ts b/tests/middleware/auth.middleware.test.ts index e6f9436..4f3eee2 100644 --- a/tests/middleware/auth.middleware.test.ts +++ b/tests/middleware/auth.middleware.test.ts @@ -3,9 +3,11 @@ * Documentation: documentation/backend/services/auth.md */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { NextResponse } from 'next/server'; import { createPrismaMock } from '../helpers/prisma'; +import crypto from 'crypto'; const prismaMock = createPrismaMock(); const verifyAccessTokenMock = vi.fn(); @@ -29,6 +31,11 @@ const makeRequest = (authHeader?: string) => ({ }, }); +// Helper to create a valid API token hash for testing +const createTestApiToken = (token: string) => { + return crypto.createHash('sha256').update(token).digest('hex'); +}; + describe('auth middleware', () => { beforeEach(() => { vi.clearAllMocks(); @@ -159,6 +166,98 @@ describe('auth middleware', () => { expect(result).toBe(true); }); + it('rejects JWT tokens for soft-deleted users', async () => { + verifyAccessTokenMock.mockReturnValue({ + sub: 'user-1', + plexId: 'plex-1', + username: 'user', + role: 'user', + iat: 1, + exp: 2, + }); + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: new Date(), + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/user not found/i); + }); + + describe('API token authentication', () => { + const testToken = 'rmab_test1234567890abcdef'; + const testTokenHash = createTestApiToken(testToken); + + it('rejects API tokens for soft-deleted users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: { + id: 'user-1', + plexUsername: 'deleteduser', + role: 'user', + deletedAt: new Date(), + }, + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/invalid.*expired/i); + }); + + it('rejects API tokens for missing users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: null, + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/invalid.*expired/i); + }); + + it('accepts valid API tokens for active users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: { + id: 'user-1', + plexUsername: 'activeuser', + role: 'user', + deletedAt: null, + }, + }); + prismaMock.apiToken.update.mockResolvedValue({}); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const handler = vi.fn(async (req: any) => + NextResponse.json({ ok: true, userId: req.user?.id }) + ); + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, handler); + const payload = await response.json(); + + expect(handler).toHaveBeenCalled(); + expect(payload.userId).toBe('user-1'); + }); + }); + it('returns current user from token', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', diff --git a/tests/utils/apiTokenRateLimit.test.ts b/tests/utils/apiTokenRateLimit.test.ts new file mode 100644 index 0000000..a80c9d8 --- /dev/null +++ b/tests/utils/apiTokenRateLimit.test.ts @@ -0,0 +1,122 @@ +/** + * Component: API Token Rate Limit Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkApiTokenCreateRateLimit, + checkApiTokenRevokeRateLimit, +} from '@/lib/utils/apiTokenRateLimit'; + +describe('API Token Rate Limiting', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('checkApiTokenCreateRateLimit', () => { + it('allows requests under the limit', () => { + const actorId = 'user-create-1'; + + for (let i = 0; i < 10; i++) { + const result = checkApiTokenCreateRateLimit(actorId); + expect(result.allowed).toBe(true); + } + }); + + it('blocks requests over the limit (10/min)', () => { + const actorId = 'user-create-2'; + + // Use up the limit + for (let i = 0; i < 10; i++) { + checkApiTokenCreateRateLimit(actorId); + } + + // 11th request should be blocked + const result = checkApiTokenCreateRateLimit(actorId); + expect(result.allowed).toBe(false); + expect(result.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('resets after the window expires', () => { + const actorId = 'user-create-3'; + + // Use up the limit + for (let i = 0; i < 10; i++) { + checkApiTokenCreateRateLimit(actorId); + } + + // Should be blocked + expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(false); + + // Advance time past the window (60 seconds) + vi.advanceTimersByTime(61 * 1000); + + // Should be allowed again + expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(true); + }); + + it('tracks different actors separately', () => { + const actor1 = 'user-create-4'; + const actor2 = 'user-create-5'; + + // Use up actor1's limit + for (let i = 0; i < 10; i++) { + checkApiTokenCreateRateLimit(actor1); + } + + // actor1 should be blocked + expect(checkApiTokenCreateRateLimit(actor1).allowed).toBe(false); + + // actor2 should still be allowed + expect(checkApiTokenCreateRateLimit(actor2).allowed).toBe(true); + }); + }); + + describe('checkApiTokenRevokeRateLimit', () => { + it('allows requests under the limit', () => { + const actorId = 'user-revoke-1'; + + for (let i = 0; i < 20; i++) { + const result = checkApiTokenRevokeRateLimit(actorId); + expect(result.allowed).toBe(true); + } + }); + + it('blocks requests over the limit (20/min)', () => { + const actorId = 'user-revoke-2'; + + // Use up the limit + for (let i = 0; i < 20; i++) { + checkApiTokenRevokeRateLimit(actorId); + } + + // 21st request should be blocked + const result = checkApiTokenRevokeRateLimit(actorId); + expect(result.allowed).toBe(false); + expect(result.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('returns correct retryAfterSeconds', () => { + const actorId = 'user-revoke-3'; + + // Use up the limit + for (let i = 0; i < 20; i++) { + checkApiTokenRevokeRateLimit(actorId); + } + + // Advance 30 seconds into the window + vi.advanceTimersByTime(30 * 1000); + + const result = checkApiTokenRevokeRateLimit(actorId); + expect(result.allowed).toBe(false); + // Should have ~30 seconds left + expect(result.retryAfterSeconds).toBeLessThanOrEqual(30); + expect(result.retryAfterSeconds).toBeGreaterThan(0); + }); + }); +}); From d6eca611fc47a6cd7c3f50e1b6d270bbf3bb20d5 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 14:51:23 -0500 Subject: [PATCH 04/10] Add API tokens management, docs & UI Introduce full API token support: add a Prisma migration to create api_tokens table and indexes; add types, constants and a generateApiToken utility (hashed token + prefix). Update admin and user token routes to use the generator, enforce per-user active token caps, and integrate rate-limit checks. Add an interactive API docs page with TokenInput, EndpointCard and ResponseViewer components, plus a protected page route. Improve confirmation UX with an accessible ConfirmDialog (focus trap, Escape to close, animations) and wire confirm flows into admin/profile token sections; also update ConfirmModal to accept node messages. Add dialog CSS animations and enhance clipboard error handling. Update related middleware, utils and tests to reflect changes. --- .../migration.sql | 33 +++ src/app/admin/components/ConfirmDialog.tsx | 227 ++++++++++++------ src/app/admin/settings/tabs/ApiTab/ApiTab.tsx | 63 +++-- src/app/api-docs/page.tsx | 141 +++++++++++ src/app/api/admin/api-tokens/route.ts | 29 ++- src/app/api/user/api-tokens/route.ts | 29 ++- src/app/globals.css | 25 ++ src/components/api-docs/EndpointCard.tsx | 157 ++++++++++++ src/components/api-docs/ResponseViewer.tsx | 151 ++++++++++++ src/components/api-docs/TokenInput.tsx | 104 ++++++++ src/components/profile/ApiTokensSection.tsx | 57 +++-- src/components/ui/ConfirmModal.tsx | 6 +- src/lib/constants/api-tokens.ts | 107 +++++++++ src/lib/middleware/auth.ts | 20 +- src/lib/types/api-tokens.ts | 23 ++ src/lib/utils/api-token.ts | 30 +++ src/lib/utils/apiTokenRateLimit.ts | 50 ++++ tests/middleware/auth.middleware.test.ts | 99 +++++++- tests/utils/apiTokenRateLimit.test.ts | 85 ++++++- 19 files changed, 1300 insertions(+), 136 deletions(-) create mode 100644 prisma/migrations/20260305000000_add_api_tokens_table/migration.sql create mode 100644 src/app/api-docs/page.tsx create mode 100644 src/components/api-docs/EndpointCard.tsx create mode 100644 src/components/api-docs/ResponseViewer.tsx create mode 100644 src/components/api-docs/TokenInput.tsx create mode 100644 src/lib/constants/api-tokens.ts create mode 100644 src/lib/types/api-tokens.ts create mode 100644 src/lib/utils/api-token.ts diff --git a/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql b/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql new file mode 100644 index 0000000..2950e36 --- /dev/null +++ b/prisma/migrations/20260305000000_add_api_tokens_table/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "api_tokens" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "token_prefix" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + "created_by_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "last_used_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "api_tokens_created_by_id_idx" ON "api_tokens"("created_by_id"); + +-- CreateIndex +CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id"); + +-- AddForeignKey +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/app/admin/components/ConfirmDialog.tsx b/src/app/admin/components/ConfirmDialog.tsx index ef71e08..6e1e10d 100644 --- a/src/app/admin/components/ConfirmDialog.tsx +++ b/src/app/admin/components/ConfirmDialog.tsx @@ -2,12 +2,13 @@ * Component: Confirm Dialog * Documentation: documentation/frontend/components.md * - * Reusable confirmation dialog for destructive actions + * Reusable confirmation dialog for destructive actions. + * Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA. */ 'use client'; -import { Fragment } from 'react'; +import React, { useEffect, useRef } from 'react'; export interface ConfirmDialogProps { isOpen: boolean; @@ -30,99 +31,177 @@ export function ConfirmDialog({ onConfirm, onCancel, }: ConfirmDialogProps) { + const cancelRef = useRef(null); + const confirmRef = useRef(null); + const dialogRef = useRef(null); + + // Focus the cancel button on open (safer default for destructive dialogs) + useEffect(() => { + if (isOpen) { + // Small delay to let animation start before stealing focus + const t = setTimeout(() => cancelRef.current?.focus(), 50); + return () => clearTimeout(t); + } + }, [isOpen]); + + // Escape to close + focus trap + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + return; + } + + // Focus trap: tab cycles only within dialog + if (e.key === 'Tab') { + const focusable = dialogRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (!focusable || focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onCancel]); + + // Prevent body scroll while open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + } + }, [isOpen]); + if (!isOpen) return null; - const confirmButtonClasses = - confirmVariant === 'danger' - ? 'bg-red-600 hover:bg-red-700 text-white' - : 'bg-blue-600 hover:bg-blue-700 text-white'; + const isDestructive = confirmVariant === 'danger'; return ( -
+
{/* Backdrop */}
+ + {/* Revoke confirmation dialog */} + + Are you sure you want to revoke{' '} + + “{tokens.find((t) => t.id === confirmRevokeId)?.name ?? 'this token'}” + + ? Any integrations using this token will immediately lose access. This cannot be undone. + + } + confirmText="Revoke token" + cancelText="Cancel" + variant="danger" + onConfirm={handleDeleteConfirmed} + onClose={() => setConfirmRevokeId(null)} + /> ); } diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 431b48d..19ea0ef 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -14,7 +14,7 @@ interface ConfirmModalProps { onClose: () => void; onConfirm: () => void; title: string; - message: string; + message: string | React.ReactNode; confirmText?: string; cancelText?: string; isLoading?: boolean; @@ -35,7 +35,9 @@ export function ConfirmModal({ return (
-

{message}

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

{message}

: message} +