synor/apps/desktop-wallet/src/pages/Recovery/RecoveryDashboard.tsx
2026-02-02 14:30:07 +05:30

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>
);
}