Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions app/me/settings/SettingsContent.tsx
Original file line number Diff line number Diff line change
@@ -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<GetMeResponse | null>(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 (
Expand Down Expand Up @@ -87,13 +98,25 @@ const SettingsContent = () => {
>
2FA
</TabsTrigger>
<TabsTrigger
value='identity'
className='text-sm font-medium text-zinc-400 transition-all data-[state=active]:text-white data-[state=active]:shadow-none'
>
Identity
</TabsTrigger>
</TabsList>
<TabsContent value='profile'>
<Profile user={userData?.user as User} />
</TabsContent>
<TabsContent value='settings'>
<Settings />
</TabsContent>
<TabsContent value='identity' className='space-y-6'>
<IdentityVerificationSection
user={userData}
onVerificationComplete={handleVerificationComplete}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</TabsContent>
</Tabs>
</div>
</div>
Expand Down
90 changes: 90 additions & 0 deletions components/didit/DiditVerifyButton.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

return (
<div className='space-y-2'>
<Button
type='button'
onClick={handleVerify}
disabled={disabled || loading}
className={clsx(className)}
>
{loading ? 'Opening verification…' : 'Verify identity'}
</Button>
{error && (
<p className='text-sm text-red-500' role='alert'>
{error}
</p>
)}
</div>
);
}
121 changes: 121 additions & 0 deletions components/didit/IdentityVerificationSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className='border-zinc-800 bg-zinc-900/30'>
<CardHeader>
<CardTitle className='text-white'>Identity verification</CardTitle>
<CardDescription className='text-zinc-400'>
Verify your identity with Didit for KYC and compliance. Your data is
processed securely.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{status && config ? (
<div className='flex flex-wrap items-center gap-3'>
<Badge
variant='outline'
className={clsx(
'inline-flex items-center gap-1.5 font-medium',
config.className
)}
>
<StatusIcon className='h-3.5 w-3.5' />
{config.label}
</Badge>
{verifiedAt && status === 'Approved' && (
<span className='text-sm text-zinc-500'>
Verified on{' '}
{new Date(verifiedAt).toLocaleDateString(undefined, {
dateStyle: 'medium',
})}
</span>
)}
</div>
) : (
<p className='text-sm text-zinc-500'>
You have not completed identity verification yet.
</p>
)}

{(status !== 'Approved' || !status) && (
<>
<DiditVerifyButton
onSuccess={() => {
onVerificationComplete?.();
}}
/>
{isLocalhost() && (
<p className='rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-xs text-zinc-500'>
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.
</p>
)}
</>
)}
</CardContent>
</Card>
);
}
49 changes: 37 additions & 12 deletions components/user/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,6 +47,8 @@ export const UserMenu: React.FC<UserMenuProps> = ({

const orgContext = useContext(OrganizationContext);
const organizations = orgContext?.organizations || [];
const isVerified =
user?.profile?.user?.identityVerificationStatus === 'Approved';

return (
<DropdownMenu>
Expand All @@ -56,16 +59,29 @@ export const UserMenu: React.FC<UserMenuProps> = ({
'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'
}
>
<Avatar className='h-7 w-7 border border-zinc-800'>
<AvatarImage
src={user?.profile?.user?.image || ''}
alt={user?.name || ''}
className='object-cover'
/>
<AvatarFallback className='from-primary/20 to-primary/5 text-primary bg-linear-to-br text-xs font-semibold'>
{(user?.name || 'U').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className='relative shrink-0'>
<Avatar className='h-7 w-7 border border-zinc-800'>
<AvatarImage
src={user?.profile?.user?.image || ''}
alt={user?.name || ''}
className='object-cover'
/>
<AvatarFallback className='from-primary/20 to-primary/5 text-primary bg-linear-to-br text-xs font-semibold'>
{(user?.name || 'U').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{isVerified && (
<span className='absolute -right-0.5 -bottom-0.5 flex rounded-full bg-black'>
<Image
src='/verified.png'
alt='Verified'
width={12}
height={12}
className='h-4 w-4'
/>
</span>
)}
</span>
<ChevronDown className='mr-1 hidden h-3.5 w-3.5 text-zinc-500 md:block' />
</button>
</DropdownMenuTrigger>
Expand All @@ -90,8 +106,17 @@ export const UserMenu: React.FC<UserMenuProps> = ({
</AvatarFallback>
</Avatar>
<div className='min-w-0 flex-1'>
<p className='truncate text-sm font-semibold text-white'>
{user?.name || 'User'}
<p className='flex items-center gap-1.5 truncate text-sm font-semibold text-white'>
<span className='truncate'>{user?.name || 'User'}</span>
{isVerified && (
<Image
src='/verified.png'
alt='Verified'
width={14}
height={14}
className='h-3.5 w-3.5 shrink-0'
/>
)}
</p>
<p className='truncate text-xs text-zinc-500'>{user?.email}</p>
</div>
Expand Down
Loading
Loading