From b7276ec6044c0691ce5b78cde537b859979a4e13 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 25 May 2026 09:37:21 +0100 Subject: [PATCH 1/2] feat(didit): drive verification UI from /didit/status, fix In Review button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: when a user's identity verification is "In Review", the verify button still shows; after the Didit hosted flow there is no clear "what happens next, how long" copy. The new backend endpoint (GET /api/didit/status, boundlessfi/ boundless-nestjs#192) returns a normalized state the frontend can switch on. This PR consumes it everywhere identity status is shown. - lib/api/didit.ts: new getDiditStatus() + VerificationStatus type. - IdentityVerificationSection: source of truth is now the status endpoint, not user.identityVerificationStatus. Verify button only renders when canStartNew === true — covers in_review and approved by construction. Each state gets its own badge, copy, and conditional review-window/decline-reason block. Light 10s poll while state is in_progress so the UI flips automatically once the webhook lands. - DiditVerifyButton: optional `label` prop so the section can show "Try verification again" / "Start new verification" where it makes sense. - VerificationSubmittedModal: state-driven copy + icon + halo. in_review uses "1-3 business days, expected by ". approved / declined / in_progress / abandoned / expired each get their own message. Accepts the full VerificationStatus so the modal can show the exact estimated-completion date from the backend. - app/api/didit/callback/route.ts: forwards the new `state` query param (still tolerates legacy ?verification=complete). Includes session_id when present, sets tab=identity so users land on the right tab. - SettingsContent: fetches getDiditStatus when ?verification=... is present, passes the status into the modal so the copy includes the precise SLA. Tab picker also honors ?tab=identity. `npx tsc --noEmit` clean, `npm run lint` clean. No tests in this repo, so verified by walking each state in the type system. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/didit/callback/route.ts | 34 ++- app/me/settings/SettingsContent.tsx | 63 ++++- components/didit/DiditVerifyButton.tsx | 4 +- .../didit/IdentityVerificationSection.tsx | 225 +++++++++++++----- .../didit/VerificationSubmittedModal.tsx | 137 ++++++++++- lib/api/didit.ts | 38 +++ 6 files changed, 413 insertions(+), 88 deletions(-) diff --git a/app/api/didit/callback/route.ts b/app/api/didit/callback/route.ts index ce0c4675..51473baf 100644 --- a/app/api/didit/callback/route.ts +++ b/app/api/didit/callback/route.ts @@ -1,17 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; -/** - * Didit redirect callback: user is sent here after completing verification on Didit. - * We redirect them to Settings → Identity so they see their updated status. - * The backend receives the final result via webhook; this route only handles the browser redirect. - */ +const KNOWN_STATES = new Set([ + 'not_started', + 'in_progress', + 'in_review', + 'approved', + 'declined', + 'abandoned', + 'expired', +]); + export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const verification = searchParams.get('verification') ?? 'complete'; + const params = request.nextUrl.searchParams; + + // Backend (`GET /api/didit/callback` on NestJS) forwards an authoritative + // `state` from the DB. Didit's hosted flow may instead arrive here directly + // with `?status=Approved` (legacy) or `?verification=complete` (legacy). + // We normalize all of those, but only the new `state` is trusted as a + // status — the rest just route the user back to settings. + const stateRaw = params.get('state'); + const state = stateRaw && KNOWN_STATES.has(stateRaw) ? stateRaw : 'in_review'; - const baseUrl = request.nextUrl.origin; - const redirectUrl = new URL('/me/settings', baseUrl); - redirectUrl.searchParams.set('verification', verification); + const redirectUrl = new URL('/me/settings', request.nextUrl.origin); + redirectUrl.searchParams.set('verification', state); + redirectUrl.searchParams.set('tab', 'identity'); + const sessionId = params.get('session_id'); + if (sessionId) redirectUrl.searchParams.set('session_id', sessionId); return NextResponse.redirect(redirectUrl, 302); } diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index f83d25fe..35555007 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -6,6 +6,11 @@ import { useSearchParams } from 'next/navigation'; import { VerificationSubmittedModal } from '@/components/didit/VerificationSubmittedModal'; import { User } from '@/types/user'; import { getMe } from '@/lib/api/auth'; +import { + getDiditStatus, + type VerificationState, + type VerificationStatus, +} from '@/lib/api/didit'; import { GetMeResponse } from '@/lib/api/types'; import { Skeleton } from '@/components/ui/skeleton'; import Settings from '@/components/profile/update/Settings'; @@ -15,9 +20,32 @@ import { IdentityVerificationSection } from '@/components/didit/IdentityVerifica import { useRef } from 'react'; import { Loader2 } from 'lucide-react'; +const KNOWN_VERIFICATION_STATES = new Set([ + 'not_started', + 'in_progress', + 'in_review', + 'approved', + 'declined', + 'abandoned', + 'expired', +]); + const SettingsContent = () => { const searchParams = useSearchParams(); - const fromVerification = searchParams.get('verification') === 'complete'; + const verificationParam = searchParams.get('verification'); + // Modal is shown when the user just returned from the Didit hosted flow: + // the legacy callback used `verification=complete`, the new callback + // forwards the resolved `state` (e.g. `verification=in_review`). + const fromVerification = Boolean(verificationParam); + const initialModalState: VerificationState | null = + verificationParam && + KNOWN_VERIFICATION_STATES.has(verificationParam as VerificationState) + ? (verificationParam as VerificationState) + : verificationParam === 'complete' + ? 'in_review' + : null; + const [verificationModalStatus, setVerificationModalStatus] = + useState(null); const [userData, setUserData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [showVerificationModal, setShowVerificationModal] = @@ -38,13 +66,35 @@ const SettingsContent = () => { }, []); useEffect(() => { - // Only set isLoading true on the very first fetch if (!hasLoadedOnce.current) { setIsLoading(true); } fetchUserData(); }, [fetchUserData]); + useEffect(() => { + if (!fromVerification) return; + let cancelled = false; + getDiditStatus() + .then(status => { + if (cancelled) return; + setVerificationModalStatus(status); + }) + .catch(() => { + if (cancelled) return; + if (initialModalState) { + setVerificationModalStatus({ + state: initialModalState, + canStartNew: false, + message: '', + }); + } + }); + return () => { + cancelled = true; + }; + }, [fromVerification, initialModalState]); + // Only show skeleton on first load — not on background refetches if (isLoading && !hasLoadedOnce.current) { return ( @@ -58,6 +108,7 @@ const SettingsContent = () => { setShowVerificationModal(false)} + status={verificationModalStatus} />
{/* Header */} @@ -70,7 +121,11 @@ const SettingsContent = () => {

@@ -171,7 +226,7 @@ const SettingsContent = () => { )} - + diff --git a/components/didit/DiditVerifyButton.tsx b/components/didit/DiditVerifyButton.tsx index 499b97f7..210a8455 100644 --- a/components/didit/DiditVerifyButton.tsx +++ b/components/didit/DiditVerifyButton.tsx @@ -9,12 +9,14 @@ export interface DiditVerifyButtonProps { onError?: (error: Error | { code?: string; message?: string }) => void; className?: string; disabled?: boolean; + label?: string; } export function DiditVerifyButton({ onError, className, disabled = false, + label = 'Verify identity', }: DiditVerifyButtonProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -50,7 +52,7 @@ export function DiditVerifyButton({ disabled={disabled || loading} className={clsx(className)} > - {loading ? 'Redirecting to verification…' : 'Verify identity'} + {loading ? 'Redirecting to verification…' : label} {error && (

diff --git a/components/didit/IdentityVerificationSection.tsx b/components/didit/IdentityVerificationSection.tsx index 084a071d..93434634 100644 --- a/components/didit/IdentityVerificationSection.tsx +++ b/components/didit/IdentityVerificationSection.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useCallback, useEffect, useState } from 'react'; import { DiditVerifyButton } from './DiditVerifyButton'; import { Card, @@ -9,39 +10,59 @@ import { CardTitle, } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { CheckCircle2, XCircle, Clock, AlertCircle } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + CheckCircle2, + XCircle, + Clock, + AlertCircle, + Hourglass, + RotateCcw, +} from 'lucide-react'; import clsx from 'clsx'; -import type { GetMeResponse } from '@/lib/api/types'; - -export type IdentityVerificationStatus = - | 'Approved' - | 'Declined' - | 'In Review' - | null - | undefined; +import { + getDiditStatus, + type VerificationState, + type VerificationStatus, +} from '@/lib/api/didit'; -export interface IdentityVerificationSectionProps { - user: GetMeResponse | null; +interface BadgeConfig { + label: string; + icon: React.ElementType; + className: string; } -const statusConfig: Record< - string, - { label: string; icon: React.ElementType; className: string } -> = { - Approved: { +const STATE_BADGES: Record = { + not_started: null, + in_progress: { + label: 'In progress', + icon: Hourglass, + className: 'text-sky-400 bg-sky-500/10 border-sky-500/20', + }, + in_review: { + label: 'Under review', + icon: Clock, + className: 'text-amber-400 bg-amber-500/10 border-amber-500/20', + }, + approved: { label: 'Verified', icon: CheckCircle2, className: 'text-emerald-500 bg-emerald-500/10 border-emerald-500/20', }, - Declined: { + 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', + abandoned: { + label: 'Not completed', + icon: AlertCircle, + className: 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20', + }, + expired: { + label: 'Expired', + icon: RotateCcw, + className: 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20', }, }; @@ -50,14 +71,39 @@ const isLocalhost = (): boolean => (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); -export function IdentityVerificationSection({ - user, -}: IdentityVerificationSectionProps) { - const status = user?.user - ?.identityVerificationStatus as IdentityVerificationStatus; - const verifiedAt = user?.user?.identityVerificationAt; - const config = status ? statusConfig[status] : null; - const StatusIcon = config?.icon ?? AlertCircle; +const formatDate = (iso?: string): string | null => { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString(undefined, { dateStyle: 'medium' }); +}; + +export function IdentityVerificationSection() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setError(null); + const next = await getDiditStatus(); + setStatus(next); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load status'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + if (status?.state !== 'in_progress') return; + const id = setInterval(refresh, 10_000); + return () => clearInterval(id); + }, [status?.state, refresh]); return ( @@ -69,47 +115,98 @@ export function IdentityVerificationSection({ - {status && config ? ( -

- + ) : error ? ( +
+ Could not load verification status: {error} +
- ) : ( -

- You have not completed identity verification yet. + ) : status ? ( + + ) : null} + + {!loading && status?.canStartNew && 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.

)} + + + ); +} - {user && status !== 'Approved' && status !== 'In Review' && ( - <> - - {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. -

+interface StatusBlockProps { + status: VerificationStatus; + onRetry: () => void; +} + +function StatusBlock({ status, onRetry }: StatusBlockProps) { + const badge = STATE_BADGES[status.state]; + const BadgeIcon = badge?.icon ?? AlertCircle; + const verifiedOn = formatDate(status.verifiedAt); + const estimatedOn = formatDate(status.reviewWindow?.estimatedCompletionAt); + + return ( +
+
+ {badge && ( + + > + + {badge.label} + )} - - + {verifiedOn && status.state === 'approved' && ( + + Verified on {verifiedOn} + + )} +
+ +

{status.message}

+ + {status.state === 'in_review' && status.reviewWindow && ( +
+ Expected completion: by {estimatedOn} ( + {status.reviewWindow.minBusinessDays}- + {status.reviewWindow.maxBusinessDays} business days). We will email + you when it’s done — no need to wait on this page. +
+ )} + + {status.state === 'declined' && status.decline?.reason && ( +
+ Reason: {status.decline.reason} +
+ )} + + {status.canStartNew && ( + + )} +
); } diff --git a/components/didit/VerificationSubmittedModal.tsx b/components/didit/VerificationSubmittedModal.tsx index efe5a2cf..620e9412 100644 --- a/components/didit/VerificationSubmittedModal.tsx +++ b/components/didit/VerificationSubmittedModal.tsx @@ -9,44 +9,163 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Clock } from 'lucide-react'; +import { CheckCircle2, Clock, XCircle, AlertCircle } from 'lucide-react'; +import clsx from 'clsx'; +import type { VerificationState, VerificationStatus } from '@/lib/api/didit'; interface VerificationSubmittedModalProps { open: boolean; onClose: () => void; + status?: VerificationStatus | null; } +interface StateCopy { + title: string; + description: (status: VerificationStatus) => string; + icon: React.ElementType; + iconClass: string; + ringClass: string; + haloClass: string; + cta: string; +} + +const COPY: Partial> = { + in_review: { + title: 'Verification submitted', + description: status => { + const { reviewWindow } = status; + if (!reviewWindow) { + return 'Your identity is under review. We will email you once it is complete.'; + } + const completion = new Date( + reviewWindow.estimatedCompletionAt + ).toLocaleDateString(undefined, { dateStyle: 'medium' }); + return `Your identity is under review. Decisions usually take ${reviewWindow.minBusinessDays}-${reviewWindow.maxBusinessDays} business days — expected by ${completion}. We will email you when it is complete.`; + }, + icon: Clock, + iconClass: 'text-amber-400', + ringClass: 'bg-amber-500/10 ring-amber-500/30', + haloClass: 'bg-amber-500/20', + cta: 'Got it', + }, + approved: { + title: 'You are verified', + description: () => + 'Your identity has been verified. You can now use all features that require KYC.', + icon: CheckCircle2, + iconClass: 'text-emerald-400', + ringClass: 'bg-emerald-500/10 ring-emerald-500/30', + haloClass: 'bg-emerald-500/20', + cta: 'Continue', + }, + declined: { + title: 'Verification was declined', + description: status => { + const reason = status.decline?.reason; + return reason + ? `Reason: ${reason}. You can try again from this page.` + : 'Your verification did not pass review. You can try again from this page.'; + }, + icon: XCircle, + iconClass: 'text-red-400', + ringClass: 'bg-red-500/10 ring-red-500/30', + haloClass: 'bg-red-500/20', + cta: 'Got it', + }, + in_progress: { + title: 'Verification in progress', + description: () => + 'We are still receiving your verification result. This page will update automatically — you can also wait for the email.', + icon: Clock, + iconClass: 'text-sky-400', + ringClass: 'bg-sky-500/10 ring-sky-500/30', + haloClass: 'bg-sky-500/20', + cta: 'Got it', + }, + abandoned: { + title: 'Verification was not completed', + description: () => + 'It looks like the verification flow ended before you finished. You can start a new verification from this page.', + icon: AlertCircle, + iconClass: 'text-zinc-300', + ringClass: 'bg-zinc-500/10 ring-zinc-500/30', + haloClass: 'bg-zinc-500/20', + cta: 'Got it', + }, + expired: { + title: 'Verification session expired', + description: () => + 'Your verification session expired before you finished. You can start a new one from this page.', + icon: AlertCircle, + iconClass: 'text-zinc-300', + ringClass: 'bg-zinc-500/10 ring-zinc-500/30', + haloClass: 'bg-zinc-500/20', + cta: 'Got it', + }, +}; + +const FALLBACK: StateCopy = { + title: 'Verification submitted', + description: () => + 'Your identity is under review. We will email you once it is complete.', + icon: Clock, + iconClass: 'text-amber-400', + ringClass: 'bg-amber-500/10 ring-amber-500/30', + haloClass: 'bg-amber-500/20', + cta: 'Got it', +}; + export function VerificationSubmittedModal({ open, onClose, + status, }: VerificationSubmittedModalProps) { + const state = status?.state ?? 'in_review'; + const copy = COPY[state] ?? FALLBACK; + const Icon = copy.icon; + const description = status + ? copy.description(status) + : copy.description({ + state, + canStartNew: false, + message: '', + } as VerificationStatus); + return ( !o && onClose()}> - {/* Animated icon */}
- - - + + +
- Verification submitted + {copy.title} - Your identity is under review. This usually takes a few minutes. We - will notify you by email once it is complete. + {description}
diff --git a/lib/api/didit.ts b/lib/api/didit.ts index 604ce894..2da1b434 100644 --- a/lib/api/didit.ts +++ b/lib/api/didit.ts @@ -49,3 +49,41 @@ export const createDiditSession = async ( status: session.status, }; }; + +export type VerificationState = + | 'not_started' + | 'in_progress' + | 'in_review' + | 'approved' + | 'declined' + | 'abandoned' + | 'expired'; + +export interface VerificationReviewWindow { + minBusinessDays: number; + maxBusinessDays: number; + estimatedCompletionAt: string; +} + +export interface VerificationDecline { + reason?: string; + canRetry: boolean; +} + +export interface VerificationStatus { + state: VerificationState; + canStartNew: boolean; + message: string; + verifiedAt?: string; + reviewedAt?: string; + reviewWindow?: VerificationReviewWindow; + decline?: VerificationDecline; +} + +export const getDiditStatus = async (): Promise => { + const res = await api.get>('/didit/status'); + if (!res.data?.data?.state) { + throw new Error('Invalid verification status response'); + } + return res.data.data; +}; From 15cb681459ec2b5b38cfef588f6d5ebdc081d098 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 25 May 2026 09:54:15 +0100 Subject: [PATCH 2/2] chore(deps): bump js-cookie 3.0.5 -> 3.0.7 to clear high-severity audit GHSA: cookie-attribute injection via prototype hijack in assign(). Patch bump; no API changes for our usage. --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index b9317fdf..0baa4977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ "gray-matter": "^4.0.3", "gsap": "^3.13.0", "input-otp": "^1.4.2", - "js-cookie": "^3.0.5", + "js-cookie": "^3.0.7", "lodash.debounce": "^4.0.8", "lucide-react": "^0.525.0", "marked": "^16.3.0", @@ -11931,12 +11931,12 @@ } }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/js-tokens": { diff --git a/package.json b/package.json index 76e7146d..cc710eb0 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "gray-matter": "^4.0.3", "gsap": "^3.13.0", "input-otp": "^1.4.2", - "js-cookie": "^3.0.5", + "js-cookie": "^3.0.7", "lodash.debounce": "^4.0.8", "lucide-react": "^0.525.0", "marked": "^16.3.0",