diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index 63bcb429a..c4546df80 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -1,28 +1,39 @@ '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'; +import { invalidateAuthProfileCache } from '@/hooks/use-auth'; + 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]); + + const handleVerificationComplete = useCallback(async () => { + await fetchUserData(); + invalidateAuthProfileCache(); + }, [fetchUserData]); if (isLoading) { return ( @@ -87,6 +98,12 @@ const SettingsContent = () => { > 2FA + + Identity + @@ -94,6 +111,12 @@ const SettingsContent = () => { + + + diff --git a/components/didit/DiditVerifyButton.tsx b/components/didit/DiditVerifyButton.tsx new file mode 100644 index 000000000..2411b3d4b --- /dev/null +++ b/components/didit/DiditVerifyButton.tsx @@ -0,0 +1,90 @@ +'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: 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'; + const error = e instanceof Error ? e : new Error(message); + setError(message); + setLoading(false); + onError?.(error); + } + }; + + 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..5a1aa96fb 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/hooks/use-auth.ts b/hooks/use-auth.ts index 6bd9dbeef..29831e101 100644 --- a/hooks/use-auth.ts +++ b/hooks/use-auth.ts @@ -4,6 +4,31 @@ import { authClient } from '@/lib/auth-client'; import { getMe } from '@/lib/api/auth'; import { GetMeResponse } from '@/lib/api/types'; +const authProfileInvalidationListeners = new Set<() => 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 +39,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 +63,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) { try { setProfileLoading(true); const profile = await getMe(); @@ -57,7 +77,7 @@ export function useAuth(requireAuth = true) { }; fetchProfile(); - }, [session, userProfile, profileLoading]); + }, [session, profileRefreshSignal]); // Redirect if required and unauthenticated useEffect(() => { @@ -100,6 +120,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 +139,7 @@ export function useAuthStatus() { useEffect(() => { const fetchProfile = async () => { - if ( - session && - 'user' in session && - session.user && - !userProfile && - !profileLoading - ) { + if (session && 'user' in session && session.user) { try { setProfileLoading(true); const profile = await getMe(); @@ -138,7 +153,7 @@ export function useAuthStatus() { }; fetchProfile(); - }, [session, userProfile]); + }, [session, profileRefreshSignal]); return { isAuthenticated: !!(session && 'user' in session && session.user), 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..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; @@ -66,6 +72,9 @@ export interface User { displayUsername: string; metadata?: Record; twoFactorEnabled: boolean; + /** Didit identity verification: Approved | Declined | In Review | null */ + identityVerificationStatus?: IdentityVerificationStatus; + 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 000000000..7019e6df0 Binary files /dev/null and b/public/verified.png differ diff --git a/types/user.ts b/types/user.ts index c78760e97..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'; @@ -166,6 +168,9 @@ export interface User { displayUsername: string; metadata?: any; twoFactorEnabled: boolean; + /** Didit identity verification: Approved | Declined | In Review | null */ + identityVerificationStatus?: IdentityVerificationStatus; + identityVerificationAt?: string | null; members?: Array<{ id: string; organizationId: string;