Skip to content

Commit b56cb43

Browse files
authored
feat: integrate Didit identity verification for KYC compliance (#440)
* feat: integrate Didit identity verification for KYC compliance * feat: implement identity verification cache invalidation and enhance error handling * fix: simplify profile fetching logic and improve cache invalidation listener execution
1 parent 5eda51b commit b56cb43

11 files changed

Lines changed: 390 additions & 40 deletions

File tree

app/me/settings/SettingsContent.tsx

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,39 @@
11
'use client';
22
import Profile from '@/components/profile/update/Profile';
33
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
4-
import { useEffect, useState } from 'react';
4+
import { useEffect, useState, useCallback } from 'react';
55
import { User } from '@/types/user';
66
import { getMe } from '@/lib/api/auth';
77
import { GetMeResponse } from '@/lib/api/types';
88
import { Skeleton } from '@/components/ui/skeleton';
99
import Settings from '@/components/profile/update/Settings';
10+
import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection';
11+
import { invalidateAuthProfileCache } from '@/hooks/use-auth';
12+
1013
const SettingsContent = () => {
1114
const [userData, setUserData] = useState<GetMeResponse | null>(null);
1215
const [isLoading, setIsLoading] = useState(true);
16+
17+
const fetchUserData = useCallback(async () => {
18+
try {
19+
const user = await getMe();
20+
setUserData(user);
21+
} catch {
22+
setUserData(null);
23+
} finally {
24+
setIsLoading(false);
25+
}
26+
}, []);
27+
1328
useEffect(() => {
14-
const fetchUserData = async () => {
15-
try {
16-
const user = await getMe();
17-
setUserData(user);
18-
} catch {
19-
setUserData(null);
20-
} finally {
21-
setIsLoading(false);
22-
}
23-
};
29+
setIsLoading(true);
2430
fetchUserData();
25-
}, []);
31+
}, [fetchUserData]);
32+
33+
const handleVerificationComplete = useCallback(async () => {
34+
await fetchUserData();
35+
invalidateAuthProfileCache();
36+
}, [fetchUserData]);
2637

2738
if (isLoading) {
2839
return (
@@ -87,13 +98,25 @@ const SettingsContent = () => {
8798
>
8899
2FA
89100
</TabsTrigger>
101+
<TabsTrigger
102+
value='identity'
103+
className='text-sm font-medium text-zinc-400 transition-all data-[state=active]:text-white data-[state=active]:shadow-none'
104+
>
105+
Identity
106+
</TabsTrigger>
90107
</TabsList>
91108
<TabsContent value='profile'>
92109
<Profile user={userData?.user as User} />
93110
</TabsContent>
94111
<TabsContent value='settings'>
95112
<Settings />
96113
</TabsContent>
114+
<TabsContent value='identity' className='space-y-6'>
115+
<IdentityVerificationSection
116+
user={userData}
117+
onVerificationComplete={handleVerificationComplete}
118+
/>
119+
</TabsContent>
97120
</Tabs>
98121
</div>
99122
</div>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Button } from '@/components/ui/button';
5+
import clsx from 'clsx';
6+
import { createDiditSession } from '@/lib/api/didit';
7+
8+
export interface DiditVerifyButtonProps {
9+
onSuccess?: (session: { sessionId?: string; status?: string }) => void;
10+
onError?: (error: Error | { code?: string; message?: string }) => void;
11+
onCancel?: () => void;
12+
className?: string;
13+
disabled?: boolean;
14+
/** Optional user id for vendor_data (backend uses authenticated user if omitted). */
15+
userId?: string;
16+
}
17+
18+
export function DiditVerifyButton({
19+
onSuccess,
20+
onError,
21+
onCancel,
22+
className,
23+
disabled = false,
24+
userId,
25+
}: DiditVerifyButtonProps) {
26+
const [loading, setLoading] = useState(false);
27+
const [error, setError] = useState<string | null>(null);
28+
29+
const handleVerify = async () => {
30+
setLoading(true);
31+
setError(null);
32+
try {
33+
const { session_token, verification_url } = await createDiditSession(
34+
userId != null ? { user_id: userId } : undefined
35+
);
36+
37+
if (!session_token || !verification_url) {
38+
throw new Error('Invalid session response');
39+
}
40+
41+
const DiditSdkModule = await import('@didit-protocol/sdk-web');
42+
const DiditSdk = DiditSdkModule.default;
43+
const sdk = DiditSdk.shared;
44+
45+
sdk.onComplete = (result: {
46+
type: string;
47+
session?: { sessionId?: string; status?: string };
48+
error?: { message?: string };
49+
}) => {
50+
setLoading(false);
51+
if (result.type === 'completed' && result.session) {
52+
setError(null);
53+
onSuccess?.(result.session);
54+
} else if (result.type === 'failed' && result.error) {
55+
setError(result.error.message ?? 'Verification failed');
56+
onError?.({ message: result.error.message });
57+
} else if (result.type === 'cancelled') {
58+
onCancel?.();
59+
}
60+
};
61+
62+
await sdk.startVerification({ url: verification_url });
63+
} catch (e) {
64+
const message =
65+
e instanceof Error ? e.message : 'Failed to start verification';
66+
const error = e instanceof Error ? e : new Error(message);
67+
setError(message);
68+
setLoading(false);
69+
onError?.(error);
70+
}
71+
};
72+
73+
return (
74+
<div className='space-y-2'>
75+
<Button
76+
type='button'
77+
onClick={handleVerify}
78+
disabled={disabled || loading}
79+
className={clsx(className)}
80+
>
81+
{loading ? 'Opening verification…' : 'Verify identity'}
82+
</Button>
83+
{error && (
84+
<p className='text-sm text-red-500' role='alert'>
85+
{error}
86+
</p>
87+
)}
88+
</div>
89+
);
90+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
'use client';
2+
3+
import { DiditVerifyButton } from './DiditVerifyButton';
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
} from '@/components/ui/card';
11+
import { Badge } from '@/components/ui/badge';
12+
import { CheckCircle2, XCircle, Clock, AlertCircle } from 'lucide-react';
13+
import clsx from 'clsx';
14+
import type { GetMeResponse } from '@/lib/api/types';
15+
16+
export type IdentityVerificationStatus =
17+
| 'Approved'
18+
| 'Declined'
19+
| 'In Review'
20+
| null
21+
| undefined;
22+
23+
export interface IdentityVerificationSectionProps {
24+
user: GetMeResponse | null;
25+
onVerificationComplete?: () => void;
26+
}
27+
28+
const statusConfig: Record<
29+
string,
30+
{ label: string; icon: React.ElementType; className: string }
31+
> = {
32+
Approved: {
33+
label: 'Verified',
34+
icon: CheckCircle2,
35+
className: 'text-emerald-500 bg-emerald-500/10 border-emerald-500/20',
36+
},
37+
Declined: {
38+
label: 'Declined',
39+
icon: XCircle,
40+
className: 'text-red-500 bg-red-500/10 border-red-500/20',
41+
},
42+
'In Review': {
43+
label: 'In review',
44+
icon: Clock,
45+
className: 'text-amber-500 bg-amber-500/10 border-amber-500/20',
46+
},
47+
};
48+
49+
const isLocalhost = (): boolean =>
50+
typeof window !== 'undefined' &&
51+
(window.location.hostname === 'localhost' ||
52+
window.location.hostname === '127.0.0.1');
53+
54+
export function IdentityVerificationSection({
55+
user,
56+
onVerificationComplete,
57+
}: IdentityVerificationSectionProps) {
58+
const status = user?.user
59+
?.identityVerificationStatus as IdentityVerificationStatus;
60+
const verifiedAt = user?.user?.identityVerificationAt;
61+
const config = status ? statusConfig[status] : null;
62+
const StatusIcon = config?.icon ?? AlertCircle;
63+
64+
return (
65+
<Card className='border-zinc-800 bg-zinc-900/30'>
66+
<CardHeader>
67+
<CardTitle className='text-white'>Identity verification</CardTitle>
68+
<CardDescription className='text-zinc-400'>
69+
Verify your identity with Didit for KYC and compliance. Your data is
70+
processed securely.
71+
</CardDescription>
72+
</CardHeader>
73+
<CardContent className='space-y-4'>
74+
{status && config ? (
75+
<div className='flex flex-wrap items-center gap-3'>
76+
<Badge
77+
variant='outline'
78+
className={clsx(
79+
'inline-flex items-center gap-1.5 font-medium',
80+
config.className
81+
)}
82+
>
83+
<StatusIcon className='h-3.5 w-3.5' />
84+
{config.label}
85+
</Badge>
86+
{verifiedAt && status === 'Approved' && (
87+
<span className='text-sm text-zinc-500'>
88+
Verified on{' '}
89+
{new Date(verifiedAt).toLocaleDateString(undefined, {
90+
dateStyle: 'medium',
91+
})}
92+
</span>
93+
)}
94+
</div>
95+
) : (
96+
<p className='text-sm text-zinc-500'>
97+
You have not completed identity verification yet.
98+
</p>
99+
)}
100+
101+
{(status !== 'Approved' || !status) && (
102+
<>
103+
<DiditVerifyButton
104+
onSuccess={() => {
105+
onVerificationComplete?.();
106+
}}
107+
/>
108+
{isLocalhost() && (
109+
<p className='rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-xs text-zinc-500'>
110+
Using localhost? If verification is blocked by the browser, use
111+
a tunnel (e.g. ngrok) and set your backend FRONTEND_URL or
112+
DIDIT_CALLBACK_URL to the tunnel URL. See DIDIT_INTEGRATION.md →
113+
Troubleshooting.
114+
</p>
115+
)}
116+
</>
117+
)}
118+
</CardContent>
119+
</Card>
120+
);
121+
}

components/user/UserMenu.tsx

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DropdownMenuTrigger,
1111
} from '@/components/ui/dropdown-menu';
1212
import Link from 'next/link';
13+
import Image from 'next/image';
1314
import React, { useContext } from 'react';
1415
import { useAuthActions, useAuthStatus } from '@/hooks/use-auth';
1516
import { OrganizationContext } from '@/lib/providers/OrganizationProvider';
@@ -46,6 +47,8 @@ export const UserMenu: React.FC<UserMenuProps> = ({
4647

4748
const orgContext = useContext(OrganizationContext);
4849
const organizations = orgContext?.organizations || [];
50+
const isVerified =
51+
user?.profile?.user?.identityVerificationStatus === 'Approved';
4952

5053
return (
5154
<DropdownMenu>
@@ -56,16 +59,29 @@ export const UserMenu: React.FC<UserMenuProps> = ({
5659
'flex items-center gap-2 rounded-lg border border-zinc-800/50 bg-zinc-900/30 p-1 transition-all hover:border-zinc-700 hover:bg-zinc-900/50'
5760
}
5861
>
59-
<Avatar className='h-7 w-7 border border-zinc-800'>
60-
<AvatarImage
61-
src={user?.profile?.user?.image || ''}
62-
alt={user?.name || ''}
63-
className='object-cover'
64-
/>
65-
<AvatarFallback className='from-primary/20 to-primary/5 text-primary bg-linear-to-br text-xs font-semibold'>
66-
{(user?.name || 'U').charAt(0).toUpperCase()}
67-
</AvatarFallback>
68-
</Avatar>
62+
<span className='relative shrink-0'>
63+
<Avatar className='h-7 w-7 border border-zinc-800'>
64+
<AvatarImage
65+
src={user?.profile?.user?.image || ''}
66+
alt={user?.name || ''}
67+
className='object-cover'
68+
/>
69+
<AvatarFallback className='from-primary/20 to-primary/5 text-primary bg-linear-to-br text-xs font-semibold'>
70+
{(user?.name || 'U').charAt(0).toUpperCase()}
71+
</AvatarFallback>
72+
</Avatar>
73+
{isVerified && (
74+
<span className='absolute -right-0.5 -bottom-0.5 flex rounded-full bg-black'>
75+
<Image
76+
src='/verified.png'
77+
alt='Verified'
78+
width={12}
79+
height={12}
80+
className='h-4 w-4'
81+
/>
82+
</span>
83+
)}
84+
</span>
6985
<ChevronDown className='mr-1 hidden h-3.5 w-3.5 text-zinc-500 md:block' />
7086
</button>
7187
</DropdownMenuTrigger>
@@ -90,8 +106,17 @@ export const UserMenu: React.FC<UserMenuProps> = ({
90106
</AvatarFallback>
91107
</Avatar>
92108
<div className='min-w-0 flex-1'>
93-
<p className='truncate text-sm font-semibold text-white'>
94-
{user?.name || 'User'}
109+
<p className='flex items-center gap-1.5 truncate text-sm font-semibold text-white'>
110+
<span className='truncate'>{user?.name || 'User'}</span>
111+
{isVerified && (
112+
<Image
113+
src='/verified.png'
114+
alt='Verified'
115+
width={14}
116+
height={14}
117+
className='h-3.5 w-3.5 shrink-0'
118+
/>
119+
)}
95120
</p>
96121
<p className='truncate text-xs text-zinc-500'>{user?.email}</p>
97122
</div>

0 commit comments

Comments
 (0)