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