veza/apps/web/src/components/developer/modals/CreateAPIKeyModal.tsx

262 lines
11 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, Key, Copy, Check, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface CreateAPIKeyModalProps {
onClose: () => void;
onCreate: (keyData: { name: string; scopes: string[] }) => Promise<{ key?: string; id: string; name: string; prefix: string }>;
}
const SCOPES = [
{ id: 'user.read', label: 'Read User Data' },
{ id: 'user.write', label: 'Update User Profile' },
{ id: 'tracks.read', label: 'Read Tracks' },
{ id: 'tracks.upload', label: 'Upload Tracks' },
{ id: 'sales.read', label: 'Read Sales Data' },
];
export const CreateAPIKeyModal: React.FC<CreateAPIKeyModalProps> = ({
onClose,
onCreate,
}) => {
const toast = useToast();
const [step, setStep] = useState(1);
const [name, setName] = useState('');
const [selectedScopes, setSelectedScopes] = useState<string[]>(['user.read']);
const [generatedKey, setGeneratedKey] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
const toggleScope = (id: string) => {
setSelectedScopes((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
);
};
const handleGenerate = async (e?: React.MouseEvent) => {
// Prevent any default behavior
e?.preventDefault();
e?.stopPropagation();
// Validate form
if (!name.trim()) {
toast.error('Please enter a name for your API key');
return;
}
if (selectedScopes.length === 0) {
toast.error('Please select at least one permission scope');
return;
}
setIsGenerating(true);
try {
// Call the parent's onCreate handler which makes the API call
const result = await onCreate({ name: name.trim(), scopes: selectedScopes });
// If the API returns a full key, use it; otherwise generate a mock for display
if (result.key) {
setGeneratedKey(result.key);
} else {
// Fallback: generate a mock key for display (in case backend doesn't return full key)
const mockKey = `vz_${Math.random().toString(36).substr(2, 8)}_${Math.random().toString(36).substr(2, 16)}`;
setGeneratedKey(mockKey);
}
setStep(2);
setIsGenerating(false);
} catch (error) {
// Error is already handled by the parent component
// Just reset the loading state
setIsGenerating(false);
// Re-throw to ensure error is handled
throw error;
}
};
const copyKey = () => {
navigator.clipboard.writeText(generatedKey);
toast.success('API Key copied to clipboard');
};
if (!mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" style={{ zIndex: 9999 }}>
<div
className="absolute inset-0 bg-background/90 backdrop-blur-md"
onClick={step === 1 ? onClose : undefined}
></div>
<div className="relative w-full max-w-2xl bg-card border border-border rounded-xl shadow-2xl animate-scaleIn overflow-hidden glass-hud flex flex-col max-h-layout-modal">
{/* Header - Fixed */}
<div className="p-6 border-b border-border/50 flex justify-between items-center bg-foreground/5 flex-none z-10">
<h3 className="text-xl font-bold text-foreground flex items-center gap-3 font-display">
<div className="w-8 h-8 rounded-lg bg-warning/20 flex items-center justify-center border border-warning/30">
<Key className="w-5 h-5 text-warning" />
</div>
{step === 1 ? 'Create API Key' : 'API Key Generated'}
</h3>
<button onClick={onClose} className="p-2 hover:bg-foreground/10 rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground hover:text-foreground" />
</button>
</div>
{/* Content - Scrollable */}
<div className="p-8 overflow-y-auto custom-scrollbar flex-1 relative">
{step === 1 ? (
<div className="space-y-8">
<div>
<label className="block text-xs font-bold text-muted-foreground uppercase tracking-wider mb-3">
Key Name
</label>
<Input
placeholder="e.g. Production Server, Mobile App"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
disabled={isGenerating}
className="bg-background/50 border-border focus:border-primary h-12 text-lg w-full"
/>
</div>
<div>
<label className="block text-xs font-bold text-muted-foreground uppercase tracking-wider mb-4">
Permissions (Scopes)
</label>
<div className="grid grid-cols-1 gap-3">
{SCOPES.map((scope) => (
<label
key={scope.id}
className={cn(
"flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-all duration-[var(--duration-fast)] group relative overflow-hidden",
selectedScopes.includes(scope.id)
? "bg-primary/10 border-primary/50 shadow-card-glow-cyan"
: "bg-background/30 border-border/50 hover:border-border hover:bg-foreground/5"
)}
>
<div className="flex items-center gap-4 z-10 relative">
<div className={cn(
"w-5 h-5 rounded border flex items-center justify-center transition-colors flex-none",
selectedScopes.includes(scope.id)
? "bg-primary border-primary"
: "border-border group-hover:border-kodo-content-dim"
)}>
{selectedScopes.includes(scope.id) && <Check className="w-3 h-3 text-black" />}
</div>
<span className={cn(
"text-sm font-medium transition-colors",
selectedScopes.includes(scope.id) ? "text-foreground" : "text-muted-foreground group-hover:text-foreground"
)}>
{scope.label}
</span>
</div>
<div className="text-xs font-mono text-muted-foreground z-10 hidden sm:block opacity-60 relative">
{scope.id}
</div>
{/* Hidden actual checkbox to keep logic working but relying on our custom UI */}
<input
type="checkbox"
className="hidden"
checked={selectedScopes.includes(scope.id)}
onChange={() => toggleScope(scope.id)}
/>
</label>
))}
</div>
</div>
</div>
) : (
<div className="text-center space-y-8 py-4">
<div className="relative w-20 h-20 mx-auto">
<div className="absolute inset-0 bg-kodo-lime/20 rounded-full animate-ping opacity-50"></div>
<div className="relative w-full h-full bg-gradient-to-br from-kodo-lime/20 to-kodo-cyan/20 rounded-full flex items-center justify-center border border-kodo-lime/30 shadow-card-glow-cyan">
<Check className="w-10 h-10 text-success drop-shadow-lg" />
</div>
</div>
<div className="space-y-2">
<h4 className="text-2xl font-bold text-foreground font-display">
Key Created Successfully
</h4>
<p className="text-sm text-muted-foreground max-w-xs mx-auto leading-relaxed">
Please copy your API key now. For security reasons, it cannot be displayed again properly.
</p>
</div>
<div className="bg-background/40 border border-border/50 rounded-xl p-1 flex items-center gap-2 relative group overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent skew-x-12 translate-x-[-200%] group-hover:animate-shimmer"></div>
<div className="flex-1 px-4 py-3 font-mono text-sm text-warning overflow-x-auto no-scrollbar whitespace-nowrap text-left">
{generatedKey}
</div>
<Button
variant="ghost"
size="icon"
onClick={copyKey}
className="h-10 w-10 text-muted-foreground hover:text-foreground hover:bg-foreground/10 rounded-lg hover:scale-105 transition-all flex-none"
title="Copy to clipboard"
>
<Copy className="w-5 h-5" />
</Button>
</div>
</div>
)}
</div>
{/* Footer - Fixed */}
<div className="p-6 border-t border-border/50 bg-foreground/5 flex justify-end gap-3 backdrop-blur-sm flex-none z-10 transition-colors">
{step === 1 ? (
<>
<Button
variant="ghost"
onClick={onClose}
disabled={isGenerating}
className="hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
>
Cancel
</Button>
<Button
variant="default"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleGenerate(e);
}}
disabled={isGenerating || !name.trim() || selectedScopes.length === 0}
type="button"
className="bg-primary hover:bg-primary/80 text-black font-semibold shadow-button-primary-glow hover:shadow-button-primary-glow-hover transition-all duration-[var(--duration-normal)]"
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
'Generate Key'
)}
</Button>
</>
) : (
<Button
onClick={onClose}
className="bg-primary hover:bg-primary/80 text-black font-semibold min-w-24 shadow-button-primary-glow hover:shadow-button-primary-glow-hover transition-all duration-[var(--duration-normal)]"
>
Done
</Button>
)}
</div>
</div>
</div>,
document.body
);
};