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