547 lines
18 KiB
TypeScript
547 lines
18 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
ShieldCheck,
|
|
UserPlus,
|
|
UserMinus,
|
|
RefreshCw,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
Clock,
|
|
Users,
|
|
Settings,
|
|
Mail,
|
|
Wallet,
|
|
Info,
|
|
AlertTriangle,
|
|
} from 'lucide-react';
|
|
import {
|
|
useRecoveryStore,
|
|
Guardian,
|
|
RecoveryRequest,
|
|
getGuardianStatusColor,
|
|
getRequestStatusColor,
|
|
} from '../../store/recovery';
|
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
|
|
|
/**
|
|
* Setup Recovery Modal
|
|
*/
|
|
function SetupRecoveryModal({ onClose }: { onClose: () => void }) {
|
|
const { setupRecovery, isLoading } = useRecoveryStore();
|
|
const [threshold, setThreshold] = useState(2);
|
|
const [delaySecs, setDelaySecs] = useState(86400); // 24 hours
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
try {
|
|
await setupRecovery(threshold, delaySecs);
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to setup recovery');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<ShieldCheck className="text-synor-400" />
|
|
Setup Social Recovery
|
|
</h2>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">
|
|
Required Approvals (Threshold)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
value={threshold}
|
|
onChange={(e) => setThreshold(parseInt(e.target.value) || 1)}
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Number of guardians required to approve recovery
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">
|
|
Recovery Delay
|
|
</label>
|
|
<select
|
|
value={delaySecs}
|
|
onChange={(e) => setDelaySecs(parseInt(e.target.value))}
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
|
>
|
|
<option value={3600}>1 hour</option>
|
|
<option value={86400}>24 hours</option>
|
|
<option value={259200}>3 days</option>
|
|
<option value={604800}>1 week</option>
|
|
</select>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Delay before recovery completes (gives time to cancel if fraudulent)
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 text-sm text-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading}
|
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg flex items-center justify-center gap-2"
|
|
>
|
|
{isLoading ? <LoadingSpinner size={18} /> : <ShieldCheck size={18} />}
|
|
Enable
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Add Guardian Modal
|
|
*/
|
|
function AddGuardianModal({ onClose }: { onClose: () => void }) {
|
|
const { addGuardian, isLoading } = useRecoveryStore();
|
|
const [name, setName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [address, setAddress] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
if (!name.trim()) {
|
|
setError('Guardian name is required');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await addGuardian(name.trim(), email.trim() || undefined, address.trim() || undefined);
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to add guardian');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<UserPlus className="text-synor-400" />
|
|
Add Guardian
|
|
</h2>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g., Mom, Best Friend"
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Email (Optional)</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="guardian@example.com"
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
For sending recovery notifications
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Synor Address (Optional)</label>
|
|
<input
|
|
type="text"
|
|
value={address}
|
|
onChange={(e) => setAddress(e.target.value)}
|
|
placeholder="synor1..."
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
If they have a Synor wallet
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 text-sm text-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading}
|
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg flex items-center justify-center gap-2"
|
|
>
|
|
{isLoading ? <LoadingSpinner size={18} /> : <UserPlus size={18} />}
|
|
Add Guardian
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Guardian Card
|
|
*/
|
|
function GuardianCard({ guardian }: { guardian: Guardian }) {
|
|
const { removeGuardian, isLoading } = useRecoveryStore();
|
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
|
|
const handleRemove = async () => {
|
|
try {
|
|
await removeGuardian(guardian.id);
|
|
} catch (err) {
|
|
// Error handled by store
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-gray-800 rounded-lg p-4">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div>
|
|
<h4 className="font-medium text-white">{guardian.name}</h4>
|
|
<span className={`text-sm ${getGuardianStatusColor(guardian.status)}`}>
|
|
{guardian.status}
|
|
</span>
|
|
</div>
|
|
{!showConfirm ? (
|
|
<button
|
|
onClick={() => setShowConfirm(true)}
|
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
|
>
|
|
<UserMinus size={18} />
|
|
</button>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowConfirm(false)}
|
|
className="px-2 py-1 text-xs bg-gray-700 rounded"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleRemove}
|
|
disabled={isLoading}
|
|
className="px-2 py-1 text-xs bg-red-600 rounded"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{guardian.email && (
|
|
<div className="flex items-center gap-2 text-sm text-gray-400 mt-2">
|
|
<Mail size={14} />
|
|
{guardian.email}
|
|
</div>
|
|
)}
|
|
|
|
{guardian.address && (
|
|
<div className="flex items-center gap-2 text-sm text-gray-400 mt-1">
|
|
<Wallet size={14} />
|
|
<span className="font-mono text-xs">{guardian.address.slice(0, 20)}...</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-xs text-gray-500 mt-2">
|
|
Added {new Date(guardian.addedAt * 1000).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Recovery Request Card
|
|
*/
|
|
function RecoveryRequestCard({ request }: { request: RecoveryRequest }) {
|
|
const { cancelRecovery, isLoading } = useRecoveryStore();
|
|
|
|
const handleCancel = async () => {
|
|
try {
|
|
await cancelRecovery(request.id);
|
|
} catch (err) {
|
|
// Error handled by store
|
|
}
|
|
};
|
|
|
|
const progress = (request.approvals.length / request.requiredApprovals) * 100;
|
|
|
|
return (
|
|
<div className="bg-gray-800 rounded-lg p-4">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div>
|
|
<span className={`text-sm font-medium ${getRequestStatusColor(request.status)}`}>
|
|
{request.status.toUpperCase()}
|
|
</span>
|
|
<p className="text-xs text-gray-500 mt-1">ID: {request.id}</p>
|
|
</div>
|
|
{request.status === 'pending' && (
|
|
<button
|
|
onClick={handleCancel}
|
|
disabled={isLoading}
|
|
className="px-3 py-1 text-sm bg-red-600 hover:bg-red-700 rounded"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Approval progress */}
|
|
<div className="mt-3">
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span className="text-gray-400">Approvals</span>
|
|
<span>
|
|
{request.approvals.length} / {request.requiredApprovals}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-synor-500"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 text-xs text-gray-500">
|
|
<p>Expires: {new Date(request.expiresAt * 1000).toLocaleString()}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Main Recovery Dashboard
|
|
*/
|
|
export default function RecoveryDashboard() {
|
|
const {
|
|
config,
|
|
requests,
|
|
isLoading,
|
|
error,
|
|
fetchConfig,
|
|
fetchRequests,
|
|
disableRecovery,
|
|
} = useRecoveryStore();
|
|
|
|
const [showSetupModal, setShowSetupModal] = useState(false);
|
|
const [showAddGuardian, setShowAddGuardian] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchConfig();
|
|
fetchRequests();
|
|
}, [fetchConfig, fetchRequests]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
|
<ShieldCheck className="text-synor-400" />
|
|
Social Recovery
|
|
</h1>
|
|
<p className="text-gray-400 mt-1">
|
|
Recover your wallet using trusted guardians
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => {
|
|
fetchConfig();
|
|
fetchRequests();
|
|
}}
|
|
disabled={isLoading}
|
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
|
>
|
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
|
<AlertCircle className="text-red-400" />
|
|
<span className="text-red-200">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Not Setup State */}
|
|
{!config && !isLoading && (
|
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
|
<ShieldCheck size={48} className="mx-auto text-gray-600 mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-400 mb-2">Social Recovery Not Configured</h3>
|
|
<p className="text-gray-500 mb-4 max-w-md mx-auto">
|
|
Set up social recovery to allow trusted friends or family members to help you
|
|
recover access to your wallet if you lose your keys.
|
|
</p>
|
|
<button
|
|
onClick={() => setShowSetupModal(true)}
|
|
className="px-6 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg inline-flex items-center gap-2"
|
|
>
|
|
<ShieldCheck size={18} />
|
|
Setup Social Recovery
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Configured State */}
|
|
{config && (
|
|
<>
|
|
{/* Status Card */}
|
|
<div className={`rounded-xl p-4 border ${config.enabled ? 'bg-green-500/10 border-green-500/30' : 'bg-red-500/10 border-red-500/30'}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{config.enabled ? (
|
|
<CheckCircle className="text-green-400" size={24} />
|
|
) : (
|
|
<AlertTriangle className="text-red-400" size={24} />
|
|
)}
|
|
<div>
|
|
<p className="font-medium text-white">
|
|
{config.enabled ? 'Recovery Enabled' : 'Recovery Disabled'}
|
|
</p>
|
|
<p className="text-sm text-gray-400">
|
|
{config.threshold} of {config.totalGuardians} guardians required
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-right">
|
|
<p className="text-sm text-gray-400">Recovery Delay</p>
|
|
<p className="font-mono">
|
|
{config.recoveryDelaySecs >= 86400
|
|
? `${Math.floor(config.recoveryDelaySecs / 86400)} days`
|
|
: `${Math.floor(config.recoveryDelaySecs / 3600)} hours`}
|
|
</p>
|
|
</div>
|
|
{config.enabled && (
|
|
<button
|
|
onClick={disableRecovery}
|
|
className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm"
|
|
>
|
|
Disable
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Guardians Section */}
|
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<Users className="text-synor-400" size={20} />
|
|
Guardians ({config.guardians.length})
|
|
</h2>
|
|
<button
|
|
onClick={() => setShowAddGuardian(true)}
|
|
className="px-3 py-1.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-sm flex items-center gap-2"
|
|
>
|
|
<UserPlus size={16} />
|
|
Add Guardian
|
|
</button>
|
|
</div>
|
|
|
|
{config.guardians.length === 0 ? (
|
|
<div className="text-center py-6 text-gray-500">
|
|
<Users size={32} className="mx-auto mb-2 opacity-50" />
|
|
<p>No guardians added yet</p>
|
|
<p className="text-sm">Add trusted contacts to enable recovery</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{config.guardians.map((guardian) => (
|
|
<GuardianCard key={guardian.id} guardian={guardian} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{config.guardians.length > 0 && config.guardians.length < config.threshold && (
|
|
<div className="mt-4 bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-yellow-200">
|
|
<AlertTriangle size={16} />
|
|
You need at least {config.threshold} guardians for recovery to work
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recovery Requests Section */}
|
|
{requests.length > 0 && (
|
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
<Clock className="text-synor-400" size={20} />
|
|
Recovery Requests
|
|
</h2>
|
|
<div className="space-y-3">
|
|
{requests.map((request) => (
|
|
<RecoveryRequestCard key={request.id} request={request} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Info Box */}
|
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
|
<div className="text-sm text-gray-400">
|
|
<p className="font-medium text-gray-300 mb-1">How Social Recovery Works</p>
|
|
<ol className="list-decimal list-inside space-y-1">
|
|
<li>Add trusted friends or family as guardians</li>
|
|
<li>If you lose access, initiate a recovery request</li>
|
|
<li>Guardians approve the request (threshold required)</li>
|
|
<li>After the delay period, recovery completes</li>
|
|
<li>You can cancel fraudulent requests during the delay</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
{showSetupModal && <SetupRecoveryModal onClose={() => setShowSetupModal(false)} />}
|
|
{showAddGuardian && <AddGuardianModal onClose={() => setShowAddGuardian(false)} />}
|
|
</div>
|
|
);
|
|
}
|