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.
This commit is contained in:
kikootwo
2026-03-04 14:51:23 -05:00
parent 45e818c181
commit d6eca611fc
19 changed files with 1300 additions and 136 deletions
@@ -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;
+153 -74
View File
@@ -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>
+42 -21
View File
@@ -7,20 +7,9 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
interface ApiToken {
id: string;
name: string;
tokenPrefix: string;
role: string;
createdBy: string;
createdById: string;
tokenUser: string;
tokenUserId: string;
lastUsedAt: string | null;
expiresAt: string | null;
createdAt: string;
}
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
import Link from 'next/link';
import type { AdminApiToken } from '@/lib/types/api-tokens';
interface UserOption {
id: string;
@@ -29,7 +18,7 @@ interface UserOption {
}
export function ApiTab() {
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [tokens, setTokens] = useState<AdminApiToken[]>([]);
const [users, setUsers] = useState<UserOption[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
@@ -42,6 +31,7 @@ export function ApiTab() {
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 {
@@ -125,7 +115,11 @@ export function ApiTab() {
}
};
const handleDelete = async (id: string) => {
const handleDeleteConfirmed = async () => {
const id = confirmRevokeId;
if (!id) return;
setConfirmRevokeId(null);
setDeletingId(id);
setError(null);
@@ -148,9 +142,13 @@ export function ApiTab() {
const handleCopy = async () => {
if (createdToken) {
await navigator.clipboard.writeText(createdToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
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.');
}
}
};
@@ -191,7 +189,10 @@ export function ApiTab() {
<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 with any role for programmatic access.
Manage API tokens for all users. Create tokens for any user with any role for programmatic access.{' '}
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
View API documentation
</Link>
</p>
</div>
@@ -384,7 +385,7 @@ export function ApiTab() {
</td>
<td className="py-3 px-2 text-right">
<button
onClick={() => handleDelete(token.id)}
onClick={() => setConfirmRevokeId(token.id)}
disabled={deletingId === token.id}
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
>
@@ -409,6 +410,26 @@ export function ApiTab() {
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
</pre>
</div>
{/* Revoke confirmation dialog */}
<ConfirmDialog
isOpen={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">
&ldquo;{tokens.find((t) => t.id === confirmRevokeId)?.name ?? 'this token'}&rdquo;
</span>
? Any integrations using this token will immediately lose access. This cannot be undone.
</>
}
confirmLabel="Revoke token"
cancelLabel="Cancel"
confirmVariant="danger"
onConfirm={handleDeleteConfirmed}
onCancel={() => setConfirmRevokeId(null)}
/>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
/**
* 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 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" \\
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/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.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>
);
}
+21 -8
View File
@@ -4,18 +4,16 @@
*/
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 { 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 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(),
@@ -104,14 +102,29 @@ export async function POST(request: NextRequest) {
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 }
);
}
// 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 { fullToken, tokenHash, tokenPrefix } = generateApiToken();
const apiToken = await prisma.apiToken.create({
data: {
+21 -8
View File
@@ -4,18 +4,16 @@
*/
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 { 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 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(),
@@ -84,11 +82,26 @@ export async function POST(request: NextRequest) {
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 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 { fullToken, tokenHash, tokenPrefix } = generateApiToken();
const apiToken = await prisma.apiToken.create({
data: {
+25
View File
@@ -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;
+157
View File
@@ -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 &quot;Authorization: Bearer {'<token>'}&quot; {typeof window !== 'undefined' ? window.location.origin : ''}{endpoint.path}
</code>
</div>
</div>
);
}
+151
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+41 -16
View File
@@ -7,16 +7,9 @@
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;
}
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import Link from 'next/link';
import type { ApiToken } from '@/lib/types/api-tokens';
export function ApiTokensSection() {
const [tokens, setTokens] = useState<ApiToken[]>([]);
@@ -29,6 +22,7 @@ export function ApiTokensSection() {
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 {
@@ -93,7 +87,11 @@ export function ApiTokensSection() {
}
};
const handleDelete = async (id: string) => {
const handleDeleteConfirmed = async () => {
const id = confirmRevokeId;
if (!id) return;
setConfirmRevokeId(null);
setDeletingId(id);
setError(null);
@@ -116,9 +114,13 @@ export function ApiTokensSection() {
const handleCopy = async () => {
if (createdToken) {
await navigator.clipboard.writeText(createdToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
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.');
}
}
};
@@ -141,7 +143,10 @@ export function ApiTokensSection() {
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.
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>
@@ -296,7 +301,7 @@ export function ApiTokensSection() {
</td>
<td className="py-3 px-2 text-right">
<button
onClick={() => handleDelete(token.id)}
onClick={() => setConfirmRevokeId(token.id)}
disabled={deletingId === token.id}
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
>
@@ -323,6 +328,26 @@ export function ApiTokensSection() {
</div>
</div>
</div>
{/* Revoke confirmation dialog */}
<ConfirmModal
isOpen={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">
&ldquo;{tokens.find((t) => t.id === confirmRevokeId)?.name ?? 'this token'}&rdquo;
</span>
? 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)}
/>
</section>
);
}
+4 -2
View File
@@ -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}>
+107
View File
@@ -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
);
}
+18 -2
View File
@@ -8,11 +8,10 @@ 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');
const API_TOKEN_PREFIX = 'rmab_';
export interface AuthenticatedRequest extends NextRequest {
user?: TokenPayload & { id: string };
}
@@ -127,6 +126,23 @@ export async function requireAuth(
);
}
// 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;
return handler(authenticatedRequest);
+23
View File
@@ -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;
}
+30
View File
@@ -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 };
}
+50
View File
@@ -1,3 +1,11 @@
/**
* 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;
@@ -10,11 +18,42 @@ type RateLimitResult = {
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) };
}
@@ -40,3 +79,14 @@ export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
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;
}
+96 -3
View File
@@ -20,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') {
@@ -231,7 +233,7 @@ describe('auth middleware', () => {
expect(payload.message).toMatch(/invalid.*expired/i);
});
it('accepts valid API tokens for active users', async () => {
it('accepts valid API tokens for active users on allowed endpoints', async () => {
prismaMock.apiToken.findUnique.mockResolvedValue({
id: 'token-1',
tokenHash: testTokenHash,
@@ -250,12 +252,103 @@ describe('auth middleware', () => {
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 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 () => {
+83 -2
View File
@@ -1,21 +1,26 @@
/**
* Component: API Token Rate Limit Tests
* Documentation: documentation/backend/services/auth.md
* Documentation: documentation/backend/services/api-tokens.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
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', () => {
@@ -119,4 +124,80 @@ describe('API Token Rate Limiting', () => {
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);
});
});
});