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 e9f01685d0
commit e9d5fef39a
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>;
}