From af80da98f1f01f501131d63eb579245aa5d7564a Mon Sep 17 00:00:00 2001 From: Ekene Ngwudike Date: Wed, 4 Mar 2026 16:55:59 +0100 Subject: [PATCH 1/3] feat: integrate Didit identity verification for KYC compliance --- app/me/settings/SettingsContent.tsx | 41 ++++-- components/didit/DiditVerifyButton.tsx | 88 +++++++++++++ .../didit/IdentityVerificationSection.tsx | 121 ++++++++++++++++++ components/user/UserMenu.tsx | 49 +++++-- lib/api/didit.ts | 51 ++++++++ lib/api/types.ts | 3 + package-lock.json | 10 ++ package.json | 1 + public/verified.png | Bin 0 -> 1210 bytes types/user.ts | 3 + 10 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 components/didit/DiditVerifyButton.tsx create mode 100644 components/didit/IdentityVerificationSection.tsx create mode 100644 lib/api/didit.ts create mode 100644 public/verified.png diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index 63bcb429a..e5c6991ef 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -1,28 +1,33 @@ 'use client'; import Profile from '@/components/profile/update/Profile'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { User } from '@/types/user'; import { getMe } from '@/lib/api/auth'; import { GetMeResponse } from '@/lib/api/types'; import { Skeleton } from '@/components/ui/skeleton'; import Settings from '@/components/profile/update/Settings'; +import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection'; + const SettingsContent = () => { const [userData, setUserData] = useState(null); const [isLoading, setIsLoading] = useState(true); + + const fetchUserData = useCallback(async () => { + try { + const user = await getMe(); + setUserData(user); + } catch { + setUserData(null); + } finally { + setIsLoading(false); + } + }, []); + useEffect(() => { - const fetchUserData = async () => { - try { - const user = await getMe(); - setUserData(user); - } catch { - setUserData(null); - } finally { - setIsLoading(false); - } - }; + setIsLoading(true); fetchUserData(); - }, []); + }, [fetchUserData]); if (isLoading) { return ( @@ -87,6 +92,12 @@ const SettingsContent = () => { > 2FA + + Identity + @@ -94,6 +105,12 @@ const SettingsContent = () => { + + + diff --git a/components/didit/DiditVerifyButton.tsx b/components/didit/DiditVerifyButton.tsx new file mode 100644 index 000000000..93ecdad71 --- /dev/null +++ b/components/didit/DiditVerifyButton.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import clsx from 'clsx'; +import { createDiditSession } from '@/lib/api/didit'; + +export interface DiditVerifyButtonProps { + onSuccess?: (session: { sessionId?: string; status?: string }) => void; + onError?: (error: { code?: string; message?: string }) => void; + onCancel?: () => void; + className?: string; + disabled?: boolean; + /** Optional user id for vendor_data (backend uses authenticated user if omitted). */ + userId?: string; +} + +export function DiditVerifyButton({ + onSuccess, + onError, + onCancel, + className, + disabled = false, + userId, +}: DiditVerifyButtonProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleVerify = async () => { + setLoading(true); + setError(null); + try { + const { session_token, verification_url } = await createDiditSession( + userId != null ? { user_id: userId } : undefined + ); + + if (!session_token || !verification_url) { + throw new Error('Invalid session response'); + } + + const DiditSdkModule = await import('@didit-protocol/sdk-web'); + const DiditSdk = DiditSdkModule.default; + const sdk = DiditSdk.shared; + + sdk.onComplete = (result: { + type: string; + session?: { sessionId?: string; status?: string }; + error?: { message?: string }; + }) => { + setLoading(false); + if (result.type === 'completed' && result.session) { + setError(null); + onSuccess?.(result.session); + } else if (result.type === 'failed' && result.error) { + setError(result.error.message ?? 'Verification failed'); + onError?.({ message: result.error.message }); + } else if (result.type === 'cancelled') { + onCancel?.(); + } + }; + + await sdk.startVerification({ url: verification_url }); + } catch (e) { + const message = + e instanceof Error ? e.message : 'Failed to start verification'; + setError(message); + setLoading(false); + } + }; + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/didit/IdentityVerificationSection.tsx b/components/didit/IdentityVerificationSection.tsx new file mode 100644 index 000000000..69bc6b7ba --- /dev/null +++ b/components/didit/IdentityVerificationSection.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { DiditVerifyButton } from './DiditVerifyButton'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { CheckCircle2, XCircle, Clock, AlertCircle } from 'lucide-react'; +import clsx from 'clsx'; +import type { GetMeResponse } from '@/lib/api/types'; + +export type IdentityVerificationStatus = + | 'Approved' + | 'Declined' + | 'In Review' + | null + | undefined; + +export interface IdentityVerificationSectionProps { + user: GetMeResponse | null; + onVerificationComplete?: () => void; +} + +const statusConfig: Record< + string, + { label: string; icon: React.ElementType; className: string } +> = { + Approved: { + label: 'Verified', + icon: CheckCircle2, + className: 'text-emerald-500 bg-emerald-500/10 border-emerald-500/20', + }, + Declined: { + label: 'Declined', + icon: XCircle, + className: 'text-red-500 bg-red-500/10 border-red-500/20', + }, + 'In Review': { + label: 'In review', + icon: Clock, + className: 'text-amber-500 bg-amber-500/10 border-amber-500/20', + }, +}; + +const isLocalhost = (): boolean => + typeof window !== 'undefined' && + (window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1'); + +export function IdentityVerificationSection({ + user, + onVerificationComplete, +}: IdentityVerificationSectionProps) { + const status = user?.user + ?.identityVerificationStatus as IdentityVerificationStatus; + const verifiedAt = user?.user?.identityVerificationAt; + const config = status ? statusConfig[status] : null; + const StatusIcon = config?.icon ?? AlertCircle; + + return ( + + + Identity verification + + Verify your identity with Didit for KYC and compliance. Your data is + processed securely. + + + + {status && config ? ( +
+ + + {config.label} + + {verifiedAt && status === 'Approved' && ( + + Verified on{' '} + {new Date(verifiedAt).toLocaleDateString(undefined, { + dateStyle: 'medium', + })} + + )} +
+ ) : ( +

+ You have not completed identity verification yet. +

+ )} + + {(status !== 'Approved' || !status) && ( + <> + { + onVerificationComplete?.(); + }} + /> + {isLocalhost() && ( +

+ Using localhost? If verification is blocked by the browser, use + a tunnel (e.g. ngrok) and set your backend FRONTEND_URL or + DIDIT_CALLBACK_URL to the tunnel URL. See DIDIT_INTEGRATION.md → + Troubleshooting. +

+ )} + + )} +
+
+ ); +} diff --git a/components/user/UserMenu.tsx b/components/user/UserMenu.tsx index 5c626da2e..8469c1388 100644 --- a/components/user/UserMenu.tsx +++ b/components/user/UserMenu.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import Link from 'next/link'; +import Image from 'next/image'; import React, { useContext } from 'react'; import { useAuthActions, useAuthStatus } from '@/hooks/use-auth'; import { OrganizationContext } from '@/lib/providers/OrganizationProvider'; @@ -46,6 +47,8 @@ export const UserMenu: React.FC = ({ const orgContext = useContext(OrganizationContext); const organizations = orgContext?.organizations || []; + const isVerified = + user?.profile?.user?.identityVerificationStatus === 'Approved'; return ( @@ -56,16 +59,29 @@ export const UserMenu: React.FC = ({ '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' } > - - - - {(user?.name || 'U').charAt(0).toUpperCase()} - - + + + + + {(user?.name || 'U').charAt(0).toUpperCase()} + + + {!isVerified && ( + + Verified + + )} + @@ -90,8 +106,17 @@ export const UserMenu: React.FC = ({
-

- {user?.name || 'User'} +

+ {user?.name || 'User'} + {isVerified && ( + Verified + )}

{user?.email}

diff --git a/lib/api/didit.ts b/lib/api/didit.ts new file mode 100644 index 000000000..604ce8945 --- /dev/null +++ b/lib/api/didit.ts @@ -0,0 +1,51 @@ +import api from './api'; +import type { ApiResponse } from './types'; + +/** Backend response shape: session is in data, verification URL is "url". */ +interface DiditSessionData { + session_id: string; + session_number?: number; + session_token: string; + url: string; + vendor_data?: string; + metadata?: unknown; + status: string; + callback?: string; + workflow_id?: string; +} + +export interface DiditCreateSessionResponse { + session_id: string; + session_token: string; + verification_url: string; + status: string; +} + +export interface CreateDiditSessionParams { + /** Optional user id for vendor_data. If omitted, backend uses authenticated user id. */ + user_id?: string; +} + +/** + * Create a Didit verification session. Backend (NestJS) creates the session and returns + * session_token and url (mapped to verification_url) for use with @didit-protocol/sdk-web. + * Requires authentication (cookies sent via api client). + */ +export const createDiditSession = async ( + params?: CreateDiditSessionParams +): Promise => { + const res = await api.post>( + '/didit/create-session', + params ?? {} + ); + const session = res.data?.data; + if (!session?.session_token || !session?.url) { + throw new Error('Invalid session response'); + } + return { + session_id: session.session_id, + session_token: session.session_token, + verification_url: session.url, + status: session.status, + }; +}; diff --git a/lib/api/types.ts b/lib/api/types.ts index 77a509765..cc2754494 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -66,6 +66,9 @@ export interface User { displayUsername: string; metadata?: Record; twoFactorEnabled: boolean; + /** Didit identity verification: Approved | Declined | In Review | null */ + identityVerificationStatus?: 'Approved' | 'Declined' | 'In Review' | null; + identityVerificationAt?: string | null; members?: Array<{ id: string; organizationId: string; diff --git a/package-lock.json b/package-lock.json index 65287815c..5193b85d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@countrystatecity/countries": "^1.0.4", "@creit.tech/stellar-wallets-kit": "^1.3.0", + "@didit-protocol/sdk-web": "^0.1.8", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -647,6 +648,15 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "license": "MIT" }, + "node_modules/@didit-protocol/sdk-web": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@didit-protocol/sdk-web/-/sdk-web-0.1.8.tgz", + "integrity": "sha512-AOnhdFBtTMyqc0mMLKd3E2K/Ri1uqDYG9dcdxj/k/BMACwZV3TCyk2DqJR8Nc3entdPLPfHz+iIqgiJDVroqhA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", diff --git a/package.json b/package.json index 4b6aa8a66..a945ed427 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@countrystatecity/countries": "^1.0.4", "@creit.tech/stellar-wallets-kit": "^1.3.0", + "@didit-protocol/sdk-web": "^0.1.8", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", diff --git a/public/verified.png b/public/verified.png new file mode 100644 index 0000000000000000000000000000000000000000..7019e6df034753862043b46d107c08c02b635e6d GIT binary patch literal 1210 zcmV;r1V#IaP)vgc# z@qBiAc6QhEq|#_L)6@N4S9jNR^?F{35+zEs37e+uslfID!}yV5{1KS`UAW?hri%CL zeXmym9Bwm$M~qNyRb%`HI!fW-azngo%74nPb3tpe5zP7=a!ApVZ zRUVOwwgh^#R*U+D0Cj9@B{`!9=Fdy`E8k*No8P zvGp;|0@I_plQ)9Tr5wkpCcmuZf$7=Y$b*p0)|>6IKzxYV3*FoS#1fk?VZT!WfboT*NfkO57@^UkNu{(moeBVqZ*`Xk;2Vwwpy#_xfM;^} zl%;MGV2e`$Hck((U(?H=0yx}J7^!-wi}$K)7ZM05fbDrkXsqq-EcE-*bsZR=j(C2o z@8j@X&lP|N&(by32p%e;=Rxo=FkZE?UHC+FSv;iPQrWkCL(AmN1l_Lc7w7i$nR?yv zJBIcoe9v!zgtw(89|#Qr<9u=C@!f&xfzvXQU+^Nvd0;!3n0())z0wH&T@-l@yS>`T zIv3=Zc++3OxV=1)03i5Y1_Gn?&t|4yS}3cVC=agMu2#t}@v2zz{ReiPlVbb2$#6!? z>w7luA36gVUrJH_4`3R|jr_uDGPilso*gMbAQm%JU{`um5^umG!8l%%C;6y0E`#o? zxsJc1ya5SO1Ru%kBg7ZHRr7#LdgFs_2X~&{M)Eb_@S)s>fH_tP z?OE;pItJ?#CF6@xd-{2hZwRm>Qh>_7?HgL31e19}9C9NsG^demYy*s=ct6S$+D*RE zDeoj|gfDc31`h2OAz$+Jr)HN@^n@LB6$&R2A?Kc-H&r4pE&PR9``5fh$dkObKPteA z#nvf%rhb3B*bO?K2>DHT_%$#-rS11zp}!s`XZlW9CPGf+$6~|XRuMRe5f(+rcg)AE z^{wUx4b!)IHQKfNJKq!y1aB#tSQ>MF(hSPP2)$i2sgzdc20ga|&{`h?VVN6*ZpjTY zehCaGd9Gn5@UFtiB`{yYJG{IA^)y*0ACq%D_E+6EWSot0!`a6EV&;RA^#hkN^)-Zw z-tcsZV4N;CLVJPn=cXRe-+T3bvLt4*qq2Gw)(E}4qCUZ&bjP`}f9N8w5Y$H2ZTTcT z-xqteUYk=|W+J>X+Ykt^OH*G*0vHdpL|*V<#~+tZ0`-Dc{xH#eRKBYb2;P(6g!!T! z1-84qUV|@TKQMh4 Date: Wed, 4 Mar 2026 17:16:10 +0100 Subject: [PATCH 2/3] feat: implement identity verification cache invalidation and enhance error handling --- app/me/settings/SettingsContent.tsx | 8 ++++- components/didit/DiditVerifyButton.tsx | 4 ++- components/user/UserMenu.tsx | 2 +- hooks/use-auth.ts | 45 +++++++++++++++++--------- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index e5c6991ef..c4546df80 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -8,6 +8,7 @@ import { GetMeResponse } from '@/lib/api/types'; import { Skeleton } from '@/components/ui/skeleton'; import Settings from '@/components/profile/update/Settings'; import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection'; +import { invalidateAuthProfileCache } from '@/hooks/use-auth'; const SettingsContent = () => { const [userData, setUserData] = useState(null); @@ -29,6 +30,11 @@ const SettingsContent = () => { fetchUserData(); }, [fetchUserData]); + const handleVerificationComplete = useCallback(async () => { + await fetchUserData(); + invalidateAuthProfileCache(); + }, [fetchUserData]); + if (isLoading) { return (
@@ -108,7 +114,7 @@ const SettingsContent = () => { diff --git a/components/didit/DiditVerifyButton.tsx b/components/didit/DiditVerifyButton.tsx index 93ecdad71..2411b3d4b 100644 --- a/components/didit/DiditVerifyButton.tsx +++ b/components/didit/DiditVerifyButton.tsx @@ -7,7 +7,7 @@ import { createDiditSession } from '@/lib/api/didit'; export interface DiditVerifyButtonProps { onSuccess?: (session: { sessionId?: string; status?: string }) => void; - onError?: (error: { code?: string; message?: string }) => void; + onError?: (error: Error | { code?: string; message?: string }) => void; onCancel?: () => void; className?: string; disabled?: boolean; @@ -63,8 +63,10 @@ export function DiditVerifyButton({ } catch (e) { const message = e instanceof Error ? e.message : 'Failed to start verification'; + const error = e instanceof Error ? e : new Error(message); setError(message); setLoading(false); + onError?.(error); } }; diff --git a/components/user/UserMenu.tsx b/components/user/UserMenu.tsx index 8469c1388..5a1aa96fb 100644 --- a/components/user/UserMenu.tsx +++ b/components/user/UserMenu.tsx @@ -70,7 +70,7 @@ export const UserMenu: React.FC = ({ {(user?.name || 'U').charAt(0).toUpperCase()} - {!isVerified && ( + {isVerified && ( void>(); + +export function invalidateAuthProfileCache() { + authProfileInvalidationListeners.forEach(listener => listener()); +} + +function useAuthProfileInvalidationSignal() { + const [refreshSignal, setRefreshSignal] = useState(0); + + useEffect(() => { + const listener = () => { + setRefreshSignal(current => current + 1); + }; + + authProfileInvalidationListeners.add(listener); + return () => { + authProfileInvalidationListeners.delete(listener); + }; + }, []); + + return refreshSignal; +} + export function useAuth(requireAuth = true) { const { data: session, @@ -14,6 +37,7 @@ export function useAuth(requireAuth = true) { const router = useRouter(); const [userProfile, setUserProfile] = useState(null); const [profileLoading, setProfileLoading] = useState(false); + const profileRefreshSignal = useAuthProfileInvalidationSignal(); const user = useMemo(() => { if (session && 'user' in session && session.user) { @@ -37,13 +61,7 @@ export function useAuth(requireAuth = true) { // Fetch profile useEffect(() => { const fetchProfile = async () => { - if ( - session && - 'user' in session && - session.user && - !userProfile && - !profileLoading - ) { + if (session && 'user' in session && session.user && !profileLoading) { try { setProfileLoading(true); const profile = await getMe(); @@ -57,7 +75,7 @@ export function useAuth(requireAuth = true) { }; fetchProfile(); - }, [session, userProfile, profileLoading]); + }, [session, profileRefreshSignal]); // Redirect if required and unauthenticated useEffect(() => { @@ -100,6 +118,7 @@ export function useAuthStatus() { const { data: session, isPending: sessionPending } = authClient.useSession(); const [userProfile, setUserProfile] = useState(null); const [profileLoading, setProfileLoading] = useState(false); + const profileRefreshSignal = useAuthProfileInvalidationSignal(); const user = useMemo(() => { if (session && 'user' in session && session.user) { @@ -118,13 +137,7 @@ export function useAuthStatus() { useEffect(() => { const fetchProfile = async () => { - if ( - session && - 'user' in session && - session.user && - !userProfile && - !profileLoading - ) { + if (session && 'user' in session && session.user && !profileLoading) { try { setProfileLoading(true); const profile = await getMe(); @@ -138,7 +151,7 @@ export function useAuthStatus() { }; fetchProfile(); - }, [session, userProfile]); + }, [session, profileRefreshSignal]); return { isAuthenticated: !!(session && 'user' in session && session.user), From bec42ee73f06a29198da8e24e9173a2a19280a37 Mon Sep 17 00:00:00 2001 From: Ekene Ngwudike Date: Wed, 4 Mar 2026 17:26:42 +0100 Subject: [PATCH 3/3] fix: simplify profile fetching logic and improve cache invalidation listener execution --- hooks/use-auth.ts | 8 +++++--- lib/api/types.ts | 8 +++++++- types/user.ts | 4 +++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/hooks/use-auth.ts b/hooks/use-auth.ts index 4a2aac84a..29831e101 100644 --- a/hooks/use-auth.ts +++ b/hooks/use-auth.ts @@ -7,7 +7,9 @@ import { GetMeResponse } from '@/lib/api/types'; const authProfileInvalidationListeners = new Set<() => void>(); export function invalidateAuthProfileCache() { - authProfileInvalidationListeners.forEach(listener => listener()); + authProfileInvalidationListeners.forEach(listener => { + listener(); + }); } function useAuthProfileInvalidationSignal() { @@ -61,7 +63,7 @@ export function useAuth(requireAuth = true) { // Fetch profile useEffect(() => { const fetchProfile = async () => { - if (session && 'user' in session && session.user && !profileLoading) { + if (session && 'user' in session && session.user) { try { setProfileLoading(true); const profile = await getMe(); @@ -137,7 +139,7 @@ export function useAuthStatus() { useEffect(() => { const fetchProfile = async () => { - if (session && 'user' in session && session.user && !profileLoading) { + if (session && 'user' in session && session.user) { try { setProfileLoading(true); const profile = await getMe(); diff --git a/lib/api/types.ts b/lib/api/types.ts index cc2754494..39010e576 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -41,6 +41,12 @@ export interface ErrorResponse extends ApiResponse { statusCode?: number; } +export type IdentityVerificationStatus = + | 'Approved' + | 'Declined' + | 'In Review' + | null; + // User type export interface UserProfile { firstName: string; @@ -67,7 +73,7 @@ export interface User { metadata?: Record; twoFactorEnabled: boolean; /** Didit identity verification: Approved | Declined | In Review | null */ - identityVerificationStatus?: 'Approved' | 'Declined' | 'In Review' | null; + identityVerificationStatus?: IdentityVerificationStatus; identityVerificationAt?: string | null; members?: Array<{ id: string; diff --git a/types/user.ts b/types/user.ts index 912882ae4..203a356ea 100644 --- a/types/user.ts +++ b/types/user.ts @@ -1,3 +1,5 @@ +import type { IdentityVerificationStatus } from '@/lib/api/types'; + export type UserRole = 'user' | 'admin' | 'moderator'; export type LoginMethod = 'email' | 'google' | 'github' | 'discord'; @@ -167,7 +169,7 @@ export interface User { metadata?: any; twoFactorEnabled: boolean; /** Didit identity verification: Approved | Declined | In Review | null */ - identityVerificationStatus?: 'Approved' | 'Declined' | 'In Review' | null; + identityVerificationStatus?: IdentityVerificationStatus; identityVerificationAt?: string | null; members?: Array<{ id: string;