261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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-kodo-void/90 backdrop-blur-md"
|
|
onClick={step === 1 ? onClose : undefined}
|
|
></div>
|
|
<div className="relative w-full max-w-2xl bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden glass-hud flex flex-col max-h-[85vh]">
|
|
{/* Header - Fixed */}
|
|
<div className="p-6 border-b border-kodo-steel/50 flex justify-between items-center bg-white/5 flex-none z-10">
|
|
<h3 className="text-xl font-bold text-white flex items-center gap-3 font-display">
|
|
<div className="w-8 h-8 rounded-lg bg-kodo-gold/20 flex items-center justify-center border border-kodo-gold/30">
|
|
<Key className="w-5 h-5 text-kodo-gold" />
|
|
</div>
|
|
{step === 1 ? 'Create API Key' : 'API Key Generated'}
|
|
</h3>
|
|
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-lg transition-colors">
|
|
<X className="w-5 h-5 text-kodo-content-dim hover:text-white" />
|
|
</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-kodo-secondary 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-kodo-void/50 border-kodo-steel focus:border-kodo-cyan h-12 text-lg w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-kodo-secondary 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-200 group relative overflow-hidden",
|
|
selectedScopes.includes(scope.id)
|
|
? "bg-kodo-cyan/10 border-kodo-cyan/50 shadow-[0_0_15px_rgba(102,252,241,0.1)]"
|
|
: "bg-kodo-void/30 border-kodo-steel/50 hover:border-kodo-steel hover:bg-white/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-kodo-cyan border-kodo-cyan"
|
|
: "border-kodo-steel 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-white" : "text-kodo-content-dim group-hover:text-kodo-text-main"
|
|
)}>
|
|
{scope.label}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs font-mono text-kodo-steel 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-[0_0_30px_rgba(54,229,209,0.2)]">
|
|
<Check className="w-10 h-10 text-kodo-lime drop-shadow-[0_0_10px_rgba(54,229,209,0.5)]" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h4 className="text-2xl font-bold text-white font-display">
|
|
Key Created Successfully
|
|
</h4>
|
|
<p className="text-sm text-kodo-content-dim 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-black/40 border border-kodo-steel/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-kodo-gold overflow-x-auto no-scrollbar whitespace-nowrap text-left">
|
|
{generatedKey}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={copyKey}
|
|
className="h-10 w-10 text-kodo-content-dim hover:text-white hover:bg-white/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-kodo-steel/50 bg-white/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-white/10 text-kodo-content-dim hover:text-white"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleGenerate(e);
|
|
}}
|
|
disabled={isGenerating || !name.trim() || selectedScopes.length === 0}
|
|
type="button"
|
|
className="bg-kodo-cyan hover:bg-kodo-cyan/80 text-black font-semibold shadow-[0_0_20px_rgba(102,252,241,0.3)] hover:shadow-[0_0_30px_rgba(102,252,241,0.5)] transition-all duration-300"
|
|
>
|
|
{isGenerating ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Generating...
|
|
</>
|
|
) : (
|
|
'Generate Key'
|
|
)}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
onClick={onClose}
|
|
className="bg-kodo-cyan hover:bg-kodo-cyan/80 text-black font-semibold min-w-[100px] shadow-[0_0_20px_rgba(102,252,241,0.3)] hover:shadow-[0_0_30px_rgba(102,252,241,0.5)] transition-all duration-300"
|
|
>
|
|
Done
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|