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
+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}>