From d6eca611fc47a6cd7c3f50e1b6d270bbf3bb20d5 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 14:51:23 -0500 Subject: [PATCH] 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} +