fix: rewrite citizenship workflow to referral-based model

- Replace governance-based KYC with trustless referral workflow
- New 3-step flow: applyForCitizenship -> approveReferral -> confirmCitizenship
- Fix FOUNDER_ADDRESS (was Alice test address)
- Use applications storage instead of legacy pendingKycApplications
- Add approveReferral, cancelApplication, confirmCitizenship functions
- Rewrite KycApprovalTab as referrer approval panel (no governance)
- Fix InviteUserModal to use peopleApi for referral pallet
- Add pending approvals section to ReferralDashboard
This commit is contained in:
2026-02-16 02:56:27 +03:00
parent 59442e58ae
commit 86dc8c1fcd
6 changed files with 728 additions and 743 deletions
+99 -446
View File
@@ -4,8 +4,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { Loader2, CheckCircle, XCircle, Clock, User, Mail, FileText, AlertTriangle } from 'lucide-react';
import { COMMISSIONS } from '@/config/commissions';
import { Loader2, CheckCircle, Clock, User, AlertTriangle } from 'lucide-react';
import {
Table,
TableBody,
@@ -14,96 +13,42 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface PendingApplication {
address: string;
cids: string[];
notes: string;
timestamp?: number;
}
interface IdentityInfo {
name: string;
email: string;
}
import { approveReferral, getPendingApprovalsForReferrer } from '@pezkuwi/lib/citizenship-workflow';
import type { PendingApproval } from '@pezkuwi/lib/citizenship-workflow';
export function KycApprovalTab() {
// identityKyc pallet is on People Chain, dynamicCommissionCollective is on Relay Chain
const { api, isApiReady, peopleApi, isPeopleReady, selectedAccount, connectWallet } = usePezkuwi();
// identityKyc pallet is on People Chain - use peopleApi
const { peopleApi, isPeopleReady, selectedAccount, connectWallet } = usePezkuwi();
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [pendingApps, setPendingApps] = useState<PendingApplication[]>([]);
const [identities, setIdentities] = useState<Map<string, IdentityInfo>>(new Map());
const [selectedApp, setSelectedApp] = useState<PendingApplication | null>(null);
const [processing, setProcessing] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [pendingApps, setPendingApps] = useState<PendingApproval[]>([]);
const [processingAddress, setProcessingAddress] = useState<string | null>(null);
// Load pending KYC applications from People Chain
// Load pending applications where current user is the referrer
useEffect(() => {
if (!peopleApi || !isPeopleReady) {
if (!peopleApi || !isPeopleReady || !selectedAccount) {
setLoading(false);
return;
}
loadPendingApplications();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peopleApi, isPeopleReady]);
}, [peopleApi, isPeopleReady, selectedAccount]);
const loadPendingApplications = async () => {
// identityKyc pallet is on People Chain
if (!peopleApi || !isPeopleReady) {
if (!peopleApi || !isPeopleReady || !selectedAccount) {
setLoading(false);
return;
}
setLoading(true);
try {
// Get all pending applications from People Chain
const entries = await peopleApi.query.identityKyc.pendingKycApplications.entries();
const apps: PendingApplication[] = [];
const identityMap = new Map<string, IdentityInfo>();
for (const [key, value] of entries) {
const address = key.args[0].toString();
const application = value.toJSON() as Record<string, unknown>;
// Get identity info for this address from People Chain
try {
const identity = await peopleApi.query.identityKyc.identities(address);
if (!identity.isEmpty) {
const identityData = identity.toJSON() as Record<string, unknown>;
identityMap.set(address, {
name: identityData.name || 'Unknown',
email: identityData.email || 'No email'
});
}
} catch (err) {
if (import.meta.env.DEV) console.error('Error fetching identity for', address, err);
}
apps.push({
address,
cids: application.cids || [],
notes: application.notes || 'No notes provided',
timestamp: application.timestamp || Date.now()
});
}
const apps = await getPendingApprovalsForReferrer(peopleApi, selectedAccount.address);
setPendingApps(apps);
setIdentities(identityMap);
if (import.meta.env.DEV) console.log(`Loaded ${apps.length} pending KYC applications`);
if (import.meta.env.DEV) console.log(`Loaded ${apps.length} pending referral approvals`);
} catch (error) {
if (import.meta.env.DEV) console.error('Error loading pending applications:', error);
toast({
@@ -116,233 +61,55 @@ export function KycApprovalTab() {
}
};
const handleApprove = async (application: PendingApplication) => {
if (!api || !selectedAccount) {
const handleApproveReferral = async (applicantAddress: string) => {
if (!peopleApi || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your admin wallet first',
description: 'Please connect your wallet first',
variant: 'destructive',
});
return;
}
setProcessing(true);
setProcessingAddress(applicantAddress);
try {
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
const result = await approveReferral(peopleApi, selectedAccount, applicantAddress);
if (import.meta.env.DEV) console.log('Proposing KYC approval for:', application.address);
if (import.meta.env.DEV) console.log('Commission member wallet:', selectedAccount.address);
// Check if user is a member of DynamicCommissionCollective
const members = await api.query.dynamicCommissionCollective.members();
const memberList = members.toJSON() as string[];
const isMember = memberList.includes(selectedAccount.address);
if (!isMember) {
if (!result.success) {
toast({
title: 'Not a Commission Member',
description: 'You are not a member of the KYC Approval Commission',
title: 'Approval Failed',
description: result.error || 'Failed to approve referral',
variant: 'destructive',
});
setProcessing(false);
return;
}
if (import.meta.env.DEV) console.log('✅ User is commission member');
// Create proposal for KYC approval
const proposal = api.tx.identityKyc.approveKyc(application.address);
const lengthBound = proposal.encodedLength;
// Create proposal directly (no proxy needed)
if (import.meta.env.DEV) console.log('Creating commission proposal for KYC approval');
if (import.meta.env.DEV) console.log('Applicant:', application.address);
if (import.meta.env.DEV) console.log('Threshold:', COMMISSIONS.KYC.threshold);
const tx = api.tx.dynamicCommissionCollective.propose(
COMMISSIONS.KYC.threshold,
proposal,
lengthBound
);
if (import.meta.env.DEV) console.log('Transaction created:', tx.toHuman());
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError, events }) => {
if (import.meta.env.DEV) console.log('Transaction status:', status.type);
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else {
errorMessage = dispatchError.toString();
}
if (import.meta.env.DEV) console.error('Approval error:', errorMessage);
toast({
title: 'Approval Failed',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
return;
}
// Check for Proposed event
if (import.meta.env.DEV) console.log('All events:', events.map(e => `${e.event.section}.${e.event.method}`));
const proposedEvent = events.find(({ event }) =>
event.section === 'dynamicCommissionCollective' && event.method === 'Proposed'
);
if (proposedEvent) {
if (import.meta.env.DEV) console.log('✅ KYC Approval proposal created');
toast({
title: 'Proposal Created',
description: `KYC approval proposed for ${application.address.slice(0, 8)}... Waiting for other commission members to vote.`,
});
resolve();
} else {
if (import.meta.env.DEV) console.warn('Transaction included but no Proposed event');
resolve();
}
}
}
).catch((error) => {
if (import.meta.env.DEV) console.error('Failed to sign and send:', error);
toast({
title: 'Transaction Error',
description: error instanceof Error ? error.message : 'Failed to submit transaction',
variant: 'destructive',
});
reject(error);
});
toast({
title: 'Referral Approved',
description: `Successfully vouched for ${applicantAddress.slice(0, 8)}...${applicantAddress.slice(-4)}`,
});
// Reload applications after approval
setTimeout(() => {
loadPendingApplications();
setShowDetailsModal(false);
setSelectedApp(null);
}, 2000);
// Reload after approval
setTimeout(() => loadPendingApplications(), 2000);
} catch (error) {
if (import.meta.env.DEV) console.error('Error approving KYC:', error);
if (import.meta.env.DEV) console.error('Error approving referral:', error);
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to approve KYC',
description: error instanceof Error ? error.message : 'Failed to approve referral',
variant: 'destructive',
});
} finally {
setProcessing(false);
setProcessingAddress(null);
}
};
const handleReject = async (application: PendingApplication) => {
if (!api || !selectedAccount) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your admin wallet first',
variant: 'destructive',
});
return;
}
const confirmReject = window.confirm(
`Are you sure you want to REJECT KYC for ${application.address}?\n\nThis will slash their deposit.`
);
if (!confirmReject) return;
setProcessing(true);
try {
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
if (import.meta.env.DEV) console.log('Rejecting KYC for:', application.address);
const tx = api.tx.identityKyc.rejectKyc(application.address);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError, events }) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else {
errorMessage = dispatchError.toString();
}
toast({
title: 'Rejection Failed',
description: errorMessage,
variant: 'destructive',
});
reject(new Error(errorMessage));
return;
}
const rejectedEvent = events.find(({ event }) =>
event.section === 'identityKyc' && event.method === 'KycRejected'
);
if (rejectedEvent) {
toast({
title: 'Rejected',
description: `KYC rejected for ${application.address.slice(0, 8)}...`,
});
resolve();
} else {
resolve();
}
}
}
).catch(reject);
});
setTimeout(() => {
loadPendingApplications();
setShowDetailsModal(false);
setSelectedApp(null);
}, 2000);
} catch (error) {
if (import.meta.env.DEV) console.error('Error rejecting KYC:', error);
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to reject KYC',
variant: 'destructive',
});
} finally {
setProcessing(false);
}
};
const openDetailsModal = (app: PendingApplication) => {
setSelectedApp(app);
setShowDetailsModal(true);
};
if (!isApiReady) {
if (!isPeopleReady) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
<span className="ml-3 text-gray-400">Connecting to blockchain...</span>
<span className="ml-3 text-gray-400">Connecting to People Chain...</span>
</div>
</CardContent>
</Card>
@@ -356,7 +123,7 @@ export function KycApprovalTab() {
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Please connect your admin wallet to view and approve KYC applications.
Please connect your wallet to view referral approvals.
<Button onClick={connectWallet} variant="outline" className="ml-4">
Connect Wallet
</Button>
@@ -368,196 +135,82 @@ export function KycApprovalTab() {
}
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Pending KYC Applications</CardTitle>
<Button onClick={loadPendingApplications} variant="outline" size="sm" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Refresh'}
</Button>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
) : pendingApps.length === 0 ? (
<div className="text-center py-12">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="text-gray-400">No pending applications</p>
<p className="text-sm text-gray-600 mt-2">All KYC applications have been processed</p>
</div>
) : (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Pending Referral Approvals</CardTitle>
<Button onClick={loadPendingApplications} variant="outline" size="sm" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Refresh'}
</Button>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
) : pendingApps.length === 0 ? (
<div className="text-center py-12">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="text-gray-400">No pending approvals</p>
<p className="text-sm text-gray-600 mt-2">No one is waiting for your referral approval</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground mb-4">
These users listed you as their referrer. Approve to vouch for their identity.
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Applicant</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Documents</TableHead>
<TableHead>Identity Hash</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingApps.map((app) => {
const identity = identities.get(app.address);
return (
<TableRow key={app.address}>
<TableCell className="font-mono text-xs">
{app.address.slice(0, 6)}...{app.address.slice(-4)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
{identity?.name || 'Loading...'}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400" />
{identity?.email || 'Loading...'}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
<FileText className="w-3 h-3 mr-1" />
{app.cids.length} CID(s)
</Badge>
</TableCell>
<TableCell>
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">
<Clock className="w-3 h-3 mr-1" />
Pending
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openDetailsModal(app)}
>
View Details
</Button>
</div>
</TableCell>
</TableRow>
);
})}
{pendingApps.map((app) => (
<TableRow key={app.applicantAddress}>
<TableCell>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
<span className="font-mono text-xs">
{app.applicantAddress.slice(0, 8)}...{app.applicantAddress.slice(-6)}
</span>
</div>
</TableCell>
<TableCell>
<span className="font-mono text-xs text-gray-500">
{app.identityHash ? `${app.identityHash.slice(0, 12)}...` : 'N/A'}
</span>
</TableCell>
<TableCell>
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">
<Clock className="w-3 h-3 mr-1" />
Pending Referral
</Badge>
</TableCell>
<TableCell>
<Button
size="sm"
onClick={() => handleApproveReferral(app.applicantAddress)}
disabled={processingAddress === app.applicantAddress}
className="bg-green-600 hover:bg-green-700"
>
{processingAddress === app.applicantAddress ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
Approve
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Details Modal */}
<Dialog open={showDetailsModal} onOpenChange={setShowDetailsModal}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>KYC Application Details</DialogTitle>
<DialogDescription>
Review application before approving or rejecting
</DialogDescription>
</DialogHeader>
{selectedApp && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-gray-400">Applicant Address</Label>
<p className="font-mono text-sm mt-1">{selectedApp.address}</p>
</div>
<div>
<Label className="text-gray-400">Name</Label>
<p className="text-sm mt-1">{identities.get(selectedApp.address)?.name || 'Unknown'}</p>
</div>
<div>
<Label className="text-gray-400">Email</Label>
<p className="text-sm mt-1">{identities.get(selectedApp.address)?.email || 'No email'}</p>
</div>
<div>
<Label className="text-gray-400">Application Time</Label>
<p className="text-sm mt-1">
{selectedApp.timestamp
? new Date(selectedApp.timestamp).toLocaleString()
: 'Unknown'}
</p>
</div>
</div>
<div>
<Label className="text-gray-400">Notes</Label>
<p className="text-sm mt-1 p-3 bg-gray-800/50 rounded border border-gray-700">
{selectedApp.notes}
</p>
</div>
<div>
<Label className="text-gray-400">IPFS Documents ({selectedApp.cids.length})</Label>
<div className="mt-2 space-y-2">
{selectedApp.cids.map((cid, index) => (
<div key={index} className="p-2 bg-gray-800/50 rounded border border-gray-700">
<p className="font-mono text-xs">{cid}</p>
<a
href={`https://ipfs.io/ipfs/${cid}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 text-xs"
>
View on IPFS
</a>
</div>
))}
</div>
</div>
<Alert className="bg-yellow-500/10 border-yellow-500/30">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
<strong>Important:</strong> Approving this application will:
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Unreserve the applicant&apos;s deposit</li>
<li>Mint a Welati (Citizen) NFT automatically</li>
<li>Enable trust score tracking</li>
<li>Grant governance voting rights</li>
</ul>
</AlertDescription>
</Alert>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setShowDetailsModal(false)}
disabled={processing}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => selectedApp && handleReject(selectedApp)}
disabled={processing}
>
{processing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <XCircle className="w-4 h-4 mr-2" />}
Reject
</Button>
<Button
onClick={() => selectedApp && handleApprove(selectedApp)}
disabled={processing}
className="bg-green-600 hover:bg-green-700"
>
{processing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
Approve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
</>
)}
</CardContent>
</Card>
);
}
function Label({ children, className }: { children: React.ReactNode; className?: string }) {
return <label className={`text-sm font-medium ${className}`}>{children}</label>;
}
@@ -10,9 +10,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2, AlertTriangle, CheckCircle, User, Users as UsersIcon, MapPin, Briefcase, Mail, Check, X, AlertCircle } from 'lucide-react';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import type { CitizenshipData, Region, MaritalStatus } from '@pezkuwi/lib/citizenship-workflow';
import { FOUNDER_ADDRESS, submitKycApplication, subscribeToKycApproval, getKycStatus } from '@pezkuwi/lib/citizenship-workflow';
import { generateCommitmentHash, generateNullifierHash, encryptData, saveLocalCitizenshipData, uploadToIPFS } from '@pezkuwi/lib/citizenship-workflow';
import { blake2AsHex } from '@pezkuwi/util-crypto';
import type { CitizenshipData, Region, MaritalStatus, KycStatus } from '@pezkuwi/lib/citizenship-workflow';
import { FOUNDER_ADDRESS, submitKycApplication, subscribeToKycApproval, getKycStatus, cancelApplication, confirmCitizenship } from '@pezkuwi/lib/citizenship-workflow';
import { encryptData, saveLocalCitizenshipData, uploadToIPFS } from '@pezkuwi/lib/citizenship-workflow';
interface NewCitizenApplicationProps {
onClose: () => void;
@@ -28,83 +29,75 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [waitingForApproval, setWaitingForApproval] = useState(false);
const [currentStatus, setCurrentStatus] = useState<KycStatus>('NotStarted');
const [kycApproved, setKycApproved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [agreed, setAgreed] = useState(false);
const [confirming, setConfirming] = useState(false);
const [canceling, setCanceling] = useState(false);
const [applicationHash, setApplicationHash] = useState<string>('');
const maritalStatus = watch('maritalStatus');
const childrenCount = watch('childrenCount');
const handleApprove = async () => {
// identityKyc pallet is on People Chain
const handleConfirmCitizenship = async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) {
setError('Please connect your wallet and wait for People Chain connection');
return;
}
setConfirming(true);
setError(null);
try {
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
const injector = await web3FromAddress(selectedAccount.address);
const result = await confirmCitizenship(peopleApi, selectedAccount);
if (import.meta.env.DEV) console.log('Confirming citizenship application on People Chain (self-confirmation)...');
if (!result.success) {
setError(result.error || 'Failed to confirm citizenship');
setConfirming(false);
return;
}
// Call confirm_citizenship() extrinsic on People Chain - self-confirmation for Welati Tiki
const tx = peopleApi.tx.identityKyc.confirmCitizenship();
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, events, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
if (import.meta.env.DEV) console.error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`);
setError(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`);
} else {
if (import.meta.env.DEV) console.error(dispatchError.toString());
setError(dispatchError.toString());
}
setConfirming(false);
return;
}
if (status.isInBlock || status.isFinalized) {
if (import.meta.env.DEV) console.log('✅ Citizenship confirmed successfully on People Chain!');
if (import.meta.env.DEV) console.log('Block hash:', status.asInBlock || status.asFinalized);
// Check for CitizenshipConfirmed event
events.forEach(({ event }) => {
if (event.section === 'identityKyc' && event.method === 'CitizenshipConfirmed') {
if (import.meta.env.DEV) console.log('📢 CitizenshipConfirmed event detected');
setKycApproved(true);
setWaitingForApproval(false);
// Redirect to citizen dashboard after 2 seconds
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
}
});
setConfirming(false);
}
});
setKycApproved(true);
setCurrentStatus('Approved');
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
} catch (err) {
if (import.meta.env.DEV) console.error('Approval error:', err);
setError((err as Error).message || 'Failed to approve application');
if (import.meta.env.DEV) console.error('Confirmation error:', err);
setError((err as Error).message || 'Failed to confirm citizenship');
} finally {
setConfirming(false);
}
};
const handleReject = async () => {
// Cancel/withdraw the application - simply close modal and go back
// No blockchain interaction needed - application will remain Pending until confirmed or admin-rejected
if (import.meta.env.DEV) console.log('Canceling citizenship application (no blockchain interaction)');
onClose();
window.location.href = '/';
const handleCancelApplication = async () => {
if (!peopleApi || !isPeopleReady || !selectedAccount) {
setError('Please connect your wallet and wait for People Chain connection');
return;
}
setCanceling(true);
setError(null);
try {
const result = await cancelApplication(peopleApi, selectedAccount);
if (!result.success) {
setError(result.error || 'Failed to cancel application');
setCanceling(false);
return;
}
if (import.meta.env.DEV) console.log('Application canceled, deposit returned');
onClose();
window.location.href = '/';
} catch (err) {
if (import.meta.env.DEV) console.error('Cancel error:', err);
setError((err as Error).message || 'Failed to cancel application');
} finally {
setCanceling(false);
}
};
// Check KYC status on mount (identityKyc pallet is on People Chain)
@@ -117,19 +110,14 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
try {
const status = await getKycStatus(peopleApi, selectedAccount.address);
if (import.meta.env.DEV) console.log('Current KYC Status from People Chain:', status);
setCurrentStatus(status);
if (status === 'Approved') {
if (import.meta.env.DEV) console.log('KYC already approved! Redirecting to dashboard...');
setKycApproved(true);
// Redirect to dashboard after 2 seconds
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
} else if (status === 'Pending') {
// If pending, show the waiting screen
setWaitingForApproval(true);
}
} catch (err) {
if (import.meta.env.DEV) console.error('Error checking KYC status:', err);
@@ -139,31 +127,34 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
checkKycStatus();
}, [peopleApi, isPeopleReady, selectedAccount, onClose]);
// Subscribe to KYC approval events on People Chain
// Subscribe to citizenship events on People Chain
useEffect(() => {
if (!peopleApi || !isPeopleReady || !selectedAccount || !waitingForApproval) {
const isPending = currentStatus === 'PendingReferral' || currentStatus === 'ReferrerApproved';
if (!peopleApi || !isPeopleReady || !selectedAccount || !isPending) {
return;
}
if (import.meta.env.DEV) console.log('Setting up KYC approval listener on People Chain for:', selectedAccount.address);
if (import.meta.env.DEV) console.log('Setting up citizenship event listener on People Chain for:', selectedAccount.address);
const unsubscribe = subscribeToKycApproval(
peopleApi,
selectedAccount.address,
() => {
if (import.meta.env.DEV) console.log('KYC Approved on People Chain! Redirecting to dashboard...');
// CitizenshipConfirmed
setKycApproved(true);
setWaitingForApproval(false);
// Redirect to citizen dashboard after 2 seconds
setCurrentStatus('Approved');
setTimeout(() => {
onClose();
window.location.href = '/dashboard';
}, 2000);
},
(error) => {
if (import.meta.env.DEV) console.error('KYC approval subscription error:', error);
setError(`Failed to monitor approval status: ${error}`);
if (import.meta.env.DEV) console.error('Citizenship event subscription error:', error);
setError(`Failed to monitor status: ${error}`);
},
() => {
// ReferralApproved - referrer vouched, now user can confirm
setCurrentStatus('ReferrerApproved');
}
);
@@ -172,7 +163,7 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
unsubscribe();
}
};
}, [peopleApi, isPeopleReady, selectedAccount, waitingForApproval, onClose]);
}, [peopleApi, isPeopleReady, selectedAccount, currentStatus, onClose]);
const onSubmit = async (data: FormData) => {
// identityKyc pallet is on People Chain
@@ -191,10 +182,10 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
try {
// Check KYC status before submitting (from People Chain)
const currentStatus = await getKycStatus(peopleApi, selectedAccount.address);
const status = await getKycStatus(peopleApi, selectedAccount.address);
if (currentStatus === 'Approved') {
setError('Your KYC has already been approved! Redirecting to dashboard...');
if (status === 'Approved') {
setError('Your citizenship is already approved! Redirecting to dashboard...');
setKycApproved(true);
setTimeout(() => {
onClose();
@@ -203,82 +194,71 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
return;
}
if (currentStatus === 'Pending') {
setError('You already have a pending KYC application. Please wait for admin approval.');
setWaitingForApproval(true);
if (status === 'PendingReferral' || status === 'ReferrerApproved') {
setError('You already have a pending citizenship application.');
setCurrentStatus(status);
return;
}
// Note: Referral initiation must be done by the REFERRER before the referee does KYC
// The referrer calls api.tx.referral.initiateReferral(refereeAddress) from InviteUserModal
// Here we just use the referrerAddress in the citizenship data if provided
if (referrerAddress) {
if (import.meta.env.DEV) console.log(`KYC application with referrer: ${referrerAddress}`);
}
// Prepare complete citizenship data
const citizenshipData: CitizenshipData = {
...data,
walletAddress: selectedAccount.address,
timestamp: Date.now(),
referralCode: data.referralCode || FOUNDER_ADDRESS // Auto-assign to founder if empty
referralCode: data.referralCode || referrerAddress || undefined
};
// Generate commitment and nullifier hashes
const commitmentHash = await generateCommitmentHash(citizenshipData);
const nullifierHash = await generateNullifierHash(selectedAccount.address, citizenshipData.timestamp);
// Compute identity hash (H256) from citizenship data
const identityHash = blake2AsHex(
JSON.stringify({
fullName: citizenshipData.fullName,
fatherName: citizenshipData.fatherName,
grandfatherName: citizenshipData.grandfatherName,
motherName: citizenshipData.motherName,
tribe: citizenshipData.tribe,
region: citizenshipData.region,
email: citizenshipData.email,
profession: citizenshipData.profession,
walletAddress: citizenshipData.walletAddress
}),
256
);
if (import.meta.env.DEV) console.log('Commitment Hash:', commitmentHash);
if (import.meta.env.DEV) console.log('Nullifier Hash:', nullifierHash);
if (import.meta.env.DEV) console.log('Identity Hash:', identityHash);
// Encrypt data
const encryptedData = await encryptData(citizenshipData, selectedAccount.address);
// Save to local storage (backup)
await saveLocalCitizenshipData(citizenshipData, selectedAccount.address);
// Upload to IPFS
// Encrypt data and upload to IPFS as off-chain backup
const encryptedData = encryptData(citizenshipData);
saveLocalCitizenshipData(citizenshipData);
const ipfsCid = await uploadToIPFS(encryptedData);
if (import.meta.env.DEV) console.log('IPFS CID (off-chain backup):', ipfsCid);
if (import.meta.env.DEV) console.log('IPFS CID:', ipfsCid);
if (import.meta.env.DEV) console.log('IPFS CID type:', typeof ipfsCid);
if (import.meta.env.DEV) console.log('IPFS CID value:', JSON.stringify(ipfsCid));
// Determine referrer: explicit referrer address > referral code > default (pallet uses DefaultReferrer)
const effectiveReferrer = referrerAddress || data.referralCode || undefined;
// Ensure ipfsCid is a string
const cidString = String(ipfsCid);
if (!cidString || cidString === 'undefined' || cidString === '[object Object]') {
throw new Error(`Invalid IPFS CID: ${cidString}`);
}
// Submit to blockchain (identityKyc pallet is on People Chain)
if (import.meta.env.DEV) console.log('Submitting KYC application to People Chain...');
// Submit to blockchain - single call: applyForCitizenship(identity_hash, referrer)
if (import.meta.env.DEV) console.log('Submitting citizenship application to People Chain...');
const result = await submitKycApplication(
peopleApi,
selectedAccount,
citizenshipData.fullName,
citizenshipData.email,
cidString,
`Citizenship application for ${citizenshipData.fullName}`
identityHash,
effectiveReferrer
);
if (!result.success) {
setError(result.error || 'Failed to submit KYC application to blockchain');
setError(result.error || 'Failed to submit citizenship application');
setSubmitting(false);
return;
}
if (import.meta.env.DEV) console.log('✅ KYC application submitted to blockchain');
if (import.meta.env.DEV) console.log('Block hash:', result.blockHash);
if (import.meta.env.DEV) console.log('Citizenship application submitted to blockchain');
// Save block hash for display
if (result.blockHash) {
setApplicationHash(result.blockHash.slice(0, 16) + '...');
}
// Move to waiting for approval state
setSubmitted(true);
setSubmitting(false);
setWaitingForApproval(true);
setCurrentStatus('PendingReferral');
} catch (err) {
if (import.meta.env.DEV) console.error('Submission error:', err);
@@ -320,34 +300,32 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
);
}
// Waiting for self-confirmation
if (waitingForApproval) {
// PendingReferral - waiting for referrer to approve
if (currentStatus === 'PendingReferral') {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-6">
{/* Icon */}
<div className="relative">
<div className="h-24 w-24 rounded-full border-4 border-primary/20 flex items-center justify-center">
<CheckCircle className="h-10 w-10 text-primary" />
<div className="h-24 w-24 rounded-full border-4 border-yellow-500/20 flex items-center justify-center">
<Loader2 className="h-10 w-10 text-yellow-500 animate-spin" />
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Confirm Your Citizenship Application</h3>
<h3 className="text-lg font-semibold">Waiting for Referrer Approval</h3>
<p className="text-sm text-muted-foreground max-w-md">
Your application has been submitted to the blockchain. Please review and confirm your identity to mint your Citizen NFT (Welati Tiki).
Your application has been submitted. Your referrer needs to vouch for your identity before you can proceed.
</p>
</div>
{/* Status steps */}
<div className="w-full max-w-md space-y-3 pt-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Data Encrypted</p>
<p className="text-xs text-muted-foreground">Your KYC data has been encrypted and stored on IPFS</p>
<p className="text-sm font-medium">Application Submitted</p>
<p className="text-xs text-muted-foreground">Transaction hash: {applicationHash || 'Confirmed'}</p>
</div>
</div>
@@ -356,8 +334,92 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Blockchain Submitted</p>
<p className="text-xs text-muted-foreground">Transaction hash: {applicationHash || 'Processing...'}</p>
<p className="text-sm font-medium">1 HEZ Deposit Reserved</p>
<p className="text-xs text-muted-foreground">Deposit will be returned if you cancel</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900">
<Loader2 className="h-5 w-5 text-yellow-600 dark:text-yellow-400 animate-spin" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Waiting for Referrer</p>
<p className="text-xs text-muted-foreground">Your referrer must approve your identity</p>
</div>
</div>
</div>
<div className="flex gap-3 w-full max-w-md pt-4">
<Button
onClick={handleCancelApplication}
disabled={canceling}
variant="destructive"
className="flex-1"
>
{canceling ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Canceling...
</>
) : (
<>
<X className="h-4 w-4 mr-2" />
Cancel Application
</>
)}
</Button>
</div>
{error && (
<Alert variant="destructive" className="w-full max-w-md">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button variant="outline" onClick={onClose} className="mt-2">
Close
</Button>
</CardContent>
</Card>
);
}
// ReferrerApproved - referrer vouched, now user can confirm
if (currentStatus === 'ReferrerApproved') {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-6">
<div className="relative">
<div className="h-24 w-24 rounded-full border-4 border-green-500/20 flex items-center justify-center">
<CheckCircle className="h-10 w-10 text-green-500" />
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold text-green-600">Referrer Approved!</h3>
<p className="text-sm text-muted-foreground max-w-md">
Your referrer has vouched for you. Confirm your citizenship to mint your Welati Tiki NFT.
</p>
</div>
<div className="w-full max-w-md space-y-3 pt-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Application Submitted</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Referrer Approved</p>
</div>
</div>
@@ -366,16 +428,15 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Awaiting Your Confirmation</p>
<p className="text-xs text-muted-foreground">Confirm or reject your application below</p>
<p className="text-sm font-medium">Confirm Citizenship</p>
<p className="text-xs text-muted-foreground">Click below to mint your Welati Tiki NFT</p>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 w-full max-w-md pt-4">
<Button
onClick={handleApprove}
onClick={handleConfirmCitizenship}
disabled={confirming}
className="flex-1 bg-green-600 hover:bg-green-700"
>
@@ -387,25 +448,7 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
) : (
<>
<Check className="h-4 w-4 mr-2" />
Approve
</>
)}
</Button>
<Button
onClick={handleReject}
disabled={confirming}
variant="destructive"
className="flex-1"
>
{confirming ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Rejecting...
</>
) : (
<>
<X className="h-4 w-4 mr-2" />
Reject
Confirm Citizenship
</>
)}
</Button>
@@ -427,7 +470,7 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
}
// Initial submission success (before blockchain confirmation)
if (submitted && !waitingForApproval) {
if (submitted && currentStatus === 'NotStarted') {
return (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center py-8 space-y-4">
@@ -630,6 +673,21 @@ export const NewCitizenApplication: React.FC<NewCitizenApplicationProps> = ({ on
</CardContent>
</Card>
{/* Deposit Notice */}
<Card className="bg-yellow-500/10 border-yellow-500/30">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-semibold text-yellow-600">1 HEZ Deposit Required</p>
<p className="text-muted-foreground mt-1">
A deposit of 1 HEZ will be reserved when you submit your application. It will be returned if you cancel your application.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Terms Agreement */}
<Card>
<CardContent className="pt-6 space-y-4">
@@ -21,7 +21,7 @@ interface InviteUserModalProps {
}
export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClose }) => {
const { api, selectedAccount } = usePezkuwi();
const { peopleApi, isPeopleReady, selectedAccount } = usePezkuwi();
const [copied, setCopied] = useState(false);
const [inviteeAddress, setInviteeAddress] = useState('');
const [initiating, setInitiating] = useState(false);
@@ -69,8 +69,8 @@ export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClos
};
const handleInitiateReferral = async () => {
if (!api || !selectedAccount || !inviteeAddress) {
setInitiateError('Please enter a valid address');
if (!peopleApi || !isPeopleReady || !selectedAccount || !inviteeAddress) {
setInitiateError('Please connect wallet and enter a valid address');
return;
}
@@ -84,13 +84,14 @@ export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClos
if (import.meta.env.DEV) console.log(`Initiating referral from ${selectedAccount.address} to ${inviteeAddress}...`);
const tx = api.tx.referral.initiateReferral(inviteeAddress);
// referral pallet is on People Chain
const tx = peopleApi.tx.referral.initiateReferral(inviteeAddress);
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => {
if (dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
const decoded = peopleApi.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else {
errorMessage = dispatchError.toString();
@@ -110,7 +111,7 @@ export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClos
});
} catch (err: unknown) {
if (import.meta.env.DEV) console.error('Failed to initiate referral:', err);
setInitiateError(err.message || 'Failed to initiate referral');
setInitiateError(err instanceof Error ? err.message : 'Failed to initiate referral');
setInitiating(false);
}
};
@@ -1,13 +1,72 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useReferral } from '@/contexts/ReferralContext';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { InviteUserModal } from './InviteUserModal';
import { Users, UserPlus, Trophy, Award, Loader2 } from 'lucide-react';
import { Users, UserPlus, Trophy, Award, Loader2, CheckCircle, Clock, User } from 'lucide-react';
import { getPendingApprovalsForReferrer, approveReferral } from '@pezkuwi/lib/citizenship-workflow';
import type { PendingApproval } from '@pezkuwi/lib/citizenship-workflow';
export const ReferralDashboard: React.FC = () => {
const { stats, myReferrals, loading } = useReferral();
const { peopleApi, isPeopleReady, selectedAccount } = usePezkuwi();
const { toast } = useToast();
const [showInviteModal, setShowInviteModal] = useState(false);
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
const [loadingApprovals, setLoadingApprovals] = useState(false);
const [processingAddress, setProcessingAddress] = useState<string | null>(null);
// Load pending approvals for this referrer
useEffect(() => {
if (!peopleApi || !isPeopleReady || !selectedAccount) return;
const loadApprovals = async () => {
setLoadingApprovals(true);
try {
const approvals = await getPendingApprovalsForReferrer(peopleApi, selectedAccount.address);
setPendingApprovals(approvals);
} catch (err) {
if (import.meta.env.DEV) console.error('Error loading pending approvals:', err);
} finally {
setLoadingApprovals(false);
}
};
loadApprovals();
}, [peopleApi, isPeopleReady, selectedAccount]);
const handleApprove = async (applicantAddress: string) => {
if (!peopleApi || !selectedAccount) return;
setProcessingAddress(applicantAddress);
try {
const result = await approveReferral(peopleApi, selectedAccount, applicantAddress);
if (result.success) {
toast({
title: 'Referral Approved',
description: `Vouched for ${applicantAddress.slice(0, 8)}...${applicantAddress.slice(-4)}`,
});
setPendingApprovals(prev => prev.filter(a => a.applicantAddress !== applicantAddress));
} else {
toast({
title: 'Approval Failed',
description: result.error || 'Failed to approve',
variant: 'destructive',
});
}
} catch (err) {
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to approve referral',
variant: 'destructive',
});
} finally {
setProcessingAddress(null);
}
};
if (loading) {
return (
@@ -135,6 +194,66 @@ export const ReferralDashboard: React.FC = () => {
</CardContent>
</Card>
{/* Pending Approvals */}
{(pendingApprovals.length > 0 || loadingApprovals) && (
<Card className="bg-yellow-900/20 border-yellow-600/30">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-500" />
Pending Approvals ({pendingApprovals.length})
</CardTitle>
<CardDescription className="text-gray-400">
These users listed you as their referrer and are waiting for your approval
</CardDescription>
</CardHeader>
<CardContent>
{loadingApprovals ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-yellow-500" />
</div>
) : (
<div className="space-y-2">
{pendingApprovals.map((approval) => (
<div
key={approval.applicantAddress}
className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-yellow-600/20 flex items-center justify-center">
<User className="w-4 h-4 text-yellow-400" />
</div>
<div>
<div className="text-sm font-mono text-white">
{approval.applicantAddress.slice(0, 10)}...{approval.applicantAddress.slice(-8)}
</div>
<div className="text-xs text-gray-500">
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30 text-[10px] px-1 py-0">
Pending Referral
</Badge>
</div>
</div>
</div>
<Button
size="sm"
onClick={() => handleApprove(approval.applicantAddress)}
disabled={processingAddress === approval.applicantAddress}
className="bg-green-600 hover:bg-green-700"
>
{processingAddress === approval.applicantAddress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<CheckCircle className="w-4 h-4 mr-1" />
)}
Approve
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* My Referrals List */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>