diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7d5f18cb..adf0a8bc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,12 +34,17 @@ import { FileUpload } from './components/FileUpload'; import { AccountCreatedCelebration } from './components/AccountCreatedCelebration'; import { TxLookup } from './components/TxLookup'; import { AddressBook } from './components/AddressBook'; +import { MultiSigTransactions } from './components/MultiSigTransactions'; +import { KYCForm } from './components/KYCForm'; +import { NotificationPreferences } from './components/NotificationPreferences'; +import { NotificationBell } from './components/NotificationBell'; import { useTheme } from './contexts/ThemeContext'; import { useAppState, useAppDispatch, A } from './store/index.js'; import { useExchangeRate } from './hooks/useExchangeRate'; const STATUS_COLORS = { connected: '#22c55e', disconnected: '#ef4444', reconnecting: '#f59e0b' }; const TIMEOUT_MS = 30000; +const KYC_LARGE_TRANSACTION_LIMIT = 1000; function withTimeout(promiseFn) { const controller = new AbortController(); @@ -60,21 +65,19 @@ function App() { const [editingLabel, setEditingLabel] = useState(false); const [labelDraft, setLabelDraft] = useState(''); const [showCelebration, setShowCelebration] = useState(false); + const [kycStatus, setKycStatus] = useState(null); + const [kycLoading, setKycLoading] = useState(false); + const [kycError, setKycError] = useState(null); const msg = useMessages(); const { canInstall, install, updateAvailable, applyUpdate } = usePWA(); const { queue: queueOffline, dequeue, pendingItems, pendingCount } = useOfflineQueue(); - const { isDark, toggleTheme } = useTheme(); - const [replaySecret, setReplaySecret] = useState(''); - const [showReplayPrompt, setShowReplayPrompt] = useState(false); - const [editingLabel, setEditingLabel] = useState(false); - const [labelDraft, setLabelDraft] = useState(''); - const [showCelebration, setShowCelebration] = useState(false); const [showTxLookup, setShowTxLookup] = useState(false); const [deepLinkHash, setDeepLinkHash] = useState(''); const [showSettings, setShowSettings] = useState(false); const [lastWsMessage, setLastWsMessage] = useState(null); - const { theme, isDark, toggleTheme } = useTheme(); + const [activeSettingsSection, setActiveSettingsSection] = useState(null); // null, 'multisig', 'kyc', 'notifications' + const { isDark, toggleTheme } = useTheme(); useRTL(); const prefersReduced = useReducedMotion(); const v = makeVariants(prefersReduced); @@ -183,6 +186,33 @@ function App() { } catch { /* non-critical */ } }, [dispatch]); + const fetchKycStatus = useCallback(async () => { + if (!account?.publicKey) { + setKycStatus(null); + return; + } + + setKycLoading(true); + try { + const { data } = await axios.get('/api/compliance/kyc/status'); + setKycStatus(data.status); + setKycError(null); + } catch (error) { + if (error.response?.status === 404) { + setKycStatus(null); + } else { + setKycError(error.response?.data?.error || 'Failed to load KYC status'); + setKycStatus(null); + } + } finally { + setKycLoading(false); + } + }, [account?.publicKey]); + + useEffect(() => { + fetchKycStatus(); + }, [fetchKycStatus]); + const saveLabel = async () => { if (!account) return; try { @@ -240,6 +270,7 @@ function App() { const amountTouched = amount.length > 0; const amountError = validateAmount(amount, xlmBalance !== null ? parseFloat(xlmBalance) : null); const amountValid = amountTouched && !amountError; + const largeTransactionBlocked = amountValid && kycStatus !== 'APPROVED' && parseFloat(amount) > KYC_LARGE_TRANSACTION_LIMIT; const handleSendMax = () => { if (xlmBalance === null) return; @@ -249,6 +280,11 @@ function App() { const sendPayment = async () => { if (!account || !recipientValid || !amountValid) return; + if (kycStatus !== 'APPROVED' && parseFloat(amount) > KYC_LARGE_TRANSACTION_LIMIT) { + msg.error(`Large transactions above ${KYC_LARGE_TRANSACTION_LIMIT} XLM require approved KYC.`); + return; + } + dispatch({ type: A.SET_LOADING, payload: 'send' }); const payload = { sourceSecret: account.secretKey, destination: recipient, amount, assetCode: 'XLM', memo: memo || undefined, memoType: memo ? memoType : undefined }; @@ -421,6 +457,7 @@ function App() { > πŸ” + {account && ( + ))} + + + + {activeSettingsSection === 'multisig' && ( + + + + )} + {activeSettingsSection === 'kyc' && ( + + + + )} + {activeSettingsSection === 'notifications' && ( + + + + )} + + {/* Path Payment */} diff --git a/frontend/src/components/KYCForm.jsx b/frontend/src/components/KYCForm.jsx new file mode 100644 index 00000000..21fd8791 --- /dev/null +++ b/frontend/src/components/KYCForm.jsx @@ -0,0 +1,349 @@ +import { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import { FormField } from './FormField'; +import { Spinner } from './Spinner'; +import { StatusMessage } from './StatusMessage'; + +const KYC_STATUS = { + PENDING: { label: 'Pending Review', color: '#f59e0b' }, + APPROVED: { label: 'Approved', color: '#22c55e' }, + REJECTED: { label: 'Rejected', color: '#ef4444' }, + UNDER_REVIEW: { label: 'Under Review', color: '#3b82f6' }, +}; + +function StatusBadge({ status }) { + const badge = KYC_STATUS[status] || { label: status, color: '#6b7280' }; + return ( + + {badge.label} + + ); +} + +export function KYCForm() { + const [step, setStep] = useState('form'); // 'form' or 'status' + const [loading, setLoading] = useState(false); + const [statusLoading, setStatusLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [kycStatus, setKycStatus] = useState(null); + + const [form, setForm] = useState({ + fullName: '', + dateOfBirth: '', + nationality: '', + documentType: 'PASSPORT', + documentNumber: '', + address: '', + phoneNumber: '', + email: '', + }); + + const [touched, setTouched] = useState({}); + + const fetchKycStatus = useCallback(async () => { + setStatusLoading(true); + try { + const { data } = await axios.get('/api/compliance/kyc/status'); + setKycStatus(data.status); + setStep('status'); + } catch (e) { + // No KYC record exists yet + if (e.response?.status === 404) { + setKycStatus(null); + setStep('form'); + } else { + setError(e.response?.data?.error || 'Failed to load KYC status'); + } + } finally { + setStatusLoading(false); + } + }, []); + + useEffect(() => { + fetchKycStatus(); + }, [fetchKycStatus]); + + const validateField = (name, value) => { + switch (name) { + case 'fullName': + return value.trim().length < 2 ? 'Name must be at least 2 characters' : null; + case 'dateOfBirth': + const dob = new Date(value); + if (isNaN(dob)) return 'Invalid date'; + const age = new Date().getFullYear() - dob.getFullYear(); + if (age < 18) return 'Must be at least 18 years old'; + return null; + case 'nationality': + return value.length < 2 ? 'Please select a valid nationality' : null; + case 'documentNumber': + return value.trim().length < 5 ? 'Document number must be at least 5 characters' : null; + case 'address': + return value.trim().length < 5 ? 'Address must be at least 5 characters' : null; + case 'email': + return value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'Invalid email' : null; + case 'phoneNumber': + return value && !/^\+?[\d\s-()]{10,}$/.test(value) ? 'Invalid phone number' : null; + default: + return null; + } + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setForm((prev) => ({ ...prev, [name]: value })); + }; + + const handleBlur = (e) => { + const { name } = e.target; + setTouched((prev) => ({ ...prev, [name]: true })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(null); + + // Validate required fields + const requiredFields = ['fullName', 'dateOfBirth', 'nationality', 'documentNumber', 'address']; + const newTouched = { ...touched }; + let hasErrors = false; + + requiredFields.forEach((field) => { + newTouched[field] = true; + if (validateField(field, form[field])) { + hasErrors = true; + } + }); + + setTouched(newTouched); + if (hasErrors) return; + + setLoading(true); + try { + const response = await axios.post('/api/compliance/kyc', { + fullName: form.fullName, + dateOfBirth: form.dateOfBirth, + nationality: form.nationality, + documentType: form.documentType, + documentNumber: form.documentNumber, + address: form.address, + phoneNumber: form.phoneNumber || undefined, + email: form.email || undefined, + }); + + setSuccess('KYC information submitted successfully! Your application is under review.'); + setKycStatus(response.data.status || 'PENDING'); + setTimeout(() => { + setStep('status'); + setSuccess(null); + }, 2000); + } catch (e) { + setError(e.response?.data?.error || 'Failed to submit KYC information'); + } finally { + setLoading(false); + } + }; + + return ( +
+

Know Your Customer (KYC)

+ + {error && } + {success && } + + {statusLoading ? ( + + ) : step === 'status' && kycStatus ? ( +
+
+

KYC Status

+ +
+

+ {kycStatus === 'APPROVED' && + 'Your identity has been verified. You can now access all features without transaction limits.'} + {kycStatus === 'PENDING' && + 'Your KYC application is being reviewed. This typically takes 1-2 business days.'} + {kycStatus === 'UNDER_REVIEW' && + 'Your application is currently under review by our compliance team.'} + {kycStatus === 'REJECTED' && + 'Unfortunately, your application was rejected. Please contact support for more information.'} +

+ {kycStatus !== 'APPROVED' && ( + + )} +
+ ) : null} + + {step === 'form' && ( +
+ + + + + + + + + + + + + + + + + + + + + +