Skip to content

Commit e083d17

Browse files
0xdevcollinsclaude
andauthored
Feat/didit verification status frontend (#592)
* feat(didit): drive verification UI from /didit/status, fix In Review button 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 <date>". 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) <noreply@anthropic.com> * 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. * feat(verify): add /verify page so backend's post-KYC redirect resolves The backend's GET /api/didit/callback (boundlessfi/boundless-nestjs#192) defaults its redirect target to ${FRONTEND_URL}/verify?state=...&session_id=... but the route did not exist — every successful KYC completion landed on a 404. Adds a polished result page that: - Reads ?state= from the URL for the initial render (so the page shows the right outcome even before the status fetch completes). - Fetches /api/didit/status for authoritative state + the precise review window / decline reason; URL state is just a hint. - Renders per-state copy + icon + halo matching the VerificationSubmittedModal: approved -> "Continue to dashboard" CTA. in_review -> "1-3 business days, expected by <date>". in_progress -> polls every 10s so the page flips when the webhook lands. declined -> shows the reason + retry button. abandoned/expired/not_started -> restart button + Settings CTA. - Falls back gracefully when the status fetch fails (e.g. unauthenticated): retries on demand, or sends the user home if there's no URL state either. Builds and registers as a static route (✓ Compiled successfully). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 781f15f commit e083d17

1 file changed

Lines changed: 335 additions & 0 deletions

File tree

app/verify/page.tsx

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
'use client';
2+
3+
import { Suspense, useCallback, useEffect, useState } from 'react';
4+
import Link from 'next/link';
5+
import { useRouter, useSearchParams } from 'next/navigation';
6+
import {
7+
AlertCircle,
8+
ArrowRight,
9+
CheckCircle2,
10+
Clock,
11+
Hourglass,
12+
Loader2,
13+
RotateCcw,
14+
XCircle,
15+
} from 'lucide-react';
16+
import clsx from 'clsx';
17+
import { Button } from '@/components/ui/button';
18+
import { Skeleton } from '@/components/ui/skeleton';
19+
import { DiditVerifyButton } from '@/components/didit/DiditVerifyButton';
20+
import {
21+
getDiditStatus,
22+
type VerificationState,
23+
type VerificationStatus,
24+
} from '@/lib/api/didit';
25+
26+
const KNOWN_STATES = new Set<VerificationState>([
27+
'not_started',
28+
'in_progress',
29+
'in_review',
30+
'approved',
31+
'declined',
32+
'abandoned',
33+
'expired',
34+
]);
35+
36+
interface StateView {
37+
title: string;
38+
body: (status: VerificationStatus) => React.ReactNode;
39+
icon: React.ElementType;
40+
iconClass: string;
41+
ringClass: string;
42+
haloClass: string;
43+
primaryHref?: string;
44+
primaryLabel: string;
45+
showVerifyButton?: boolean;
46+
}
47+
48+
const formatDate = (iso?: string): string | null => {
49+
if (!iso) return null;
50+
const d = new Date(iso);
51+
return Number.isNaN(d.getTime())
52+
? null
53+
: d.toLocaleDateString(undefined, { dateStyle: 'medium' });
54+
};
55+
56+
const VIEWS: Record<VerificationState, StateView> = {
57+
approved: {
58+
title: 'You are verified',
59+
body: status => {
60+
const on = formatDate(status.verifiedAt);
61+
return on
62+
? `Your identity was verified on ${on}. You can now use all features that require KYC.`
63+
: 'Your identity has been verified. You can now use all features that require KYC.';
64+
},
65+
icon: CheckCircle2,
66+
iconClass: 'text-emerald-400',
67+
ringClass: 'bg-emerald-500/10 ring-emerald-500/30',
68+
haloClass: 'bg-emerald-500/20',
69+
primaryHref: '/me',
70+
primaryLabel: 'Continue to dashboard',
71+
},
72+
in_review: {
73+
title: 'Verification submitted',
74+
body: status => {
75+
const w = status.reviewWindow;
76+
if (!w) {
77+
return 'Your identity is under review. We will email you once it is complete.';
78+
}
79+
const completion = formatDate(w.estimatedCompletionAt);
80+
return (
81+
<>
82+
Your identity is under review. Decisions usually take{' '}
83+
<strong>
84+
{w.minBusinessDays}-{w.maxBusinessDays} business days
85+
</strong>
86+
{completion ? (
87+
<>
88+
{' '}
89+
&mdash; expected by <strong>{completion}</strong>
90+
</>
91+
) : null}
92+
. We will email you when it is complete.
93+
</>
94+
);
95+
},
96+
icon: Clock,
97+
iconClass: 'text-amber-400',
98+
ringClass: 'bg-amber-500/10 ring-amber-500/30',
99+
haloClass: 'bg-amber-500/20',
100+
primaryHref: '/me',
101+
primaryLabel: 'Back to dashboard',
102+
},
103+
in_progress: {
104+
title: 'Verification in progress',
105+
body: () =>
106+
'We are still receiving the result from our verification provider. This page will refresh automatically — you can also wait for the email.',
107+
icon: Hourglass,
108+
iconClass: 'text-sky-400',
109+
ringClass: 'bg-sky-500/10 ring-sky-500/30',
110+
haloClass: 'bg-sky-500/20',
111+
primaryHref: '/me/settings?tab=identity',
112+
primaryLabel: 'View status',
113+
},
114+
declined: {
115+
title: 'Verification was declined',
116+
body: status => {
117+
const reason = status.decline?.reason;
118+
return reason
119+
? `Reason: ${reason}. You can try the verification again below.`
120+
: 'Your verification did not pass review. You can try again below.';
121+
},
122+
icon: XCircle,
123+
iconClass: 'text-red-400',
124+
ringClass: 'bg-red-500/10 ring-red-500/30',
125+
haloClass: 'bg-red-500/20',
126+
primaryHref: '/me/settings?tab=identity',
127+
primaryLabel: 'View details',
128+
showVerifyButton: true,
129+
},
130+
abandoned: {
131+
title: 'Verification was not completed',
132+
body: () =>
133+
'It looks like the verification flow ended before you finished. You can start a new verification below.',
134+
icon: AlertCircle,
135+
iconClass: 'text-zinc-300',
136+
ringClass: 'bg-zinc-500/10 ring-zinc-500/30',
137+
haloClass: 'bg-zinc-500/20',
138+
primaryHref: '/me/settings?tab=identity',
139+
primaryLabel: 'Back to settings',
140+
showVerifyButton: true,
141+
},
142+
expired: {
143+
title: 'Verification session expired',
144+
body: () =>
145+
'Your verification session expired before you finished. You can start a new one below.',
146+
icon: RotateCcw,
147+
iconClass: 'text-zinc-300',
148+
ringClass: 'bg-zinc-500/10 ring-zinc-500/30',
149+
haloClass: 'bg-zinc-500/20',
150+
primaryHref: '/me/settings?tab=identity',
151+
primaryLabel: 'Back to settings',
152+
showVerifyButton: true,
153+
},
154+
not_started: {
155+
title: 'Start identity verification',
156+
body: () =>
157+
'You have not started identity verification yet. Verify your identity to unlock features that require KYC.',
158+
icon: AlertCircle,
159+
iconClass: 'text-zinc-300',
160+
ringClass: 'bg-zinc-500/10 ring-zinc-500/30',
161+
haloClass: 'bg-zinc-500/20',
162+
primaryHref: '/me/settings?tab=identity',
163+
primaryLabel: 'Back to settings',
164+
showVerifyButton: true,
165+
},
166+
};
167+
168+
function parseStateParam(raw: string | null): VerificationState | null {
169+
if (!raw) return null;
170+
return KNOWN_STATES.has(raw as VerificationState)
171+
? (raw as VerificationState)
172+
: null;
173+
}
174+
175+
function VerifyResult() {
176+
const router = useRouter();
177+
const searchParams = useSearchParams();
178+
const initialState = parseStateParam(searchParams.get('state'));
179+
180+
const [status, setStatus] = useState<VerificationStatus | null>(null);
181+
const [loading, setLoading] = useState(true);
182+
const [error, setError] = useState<string | null>(null);
183+
184+
const refresh = useCallback(async () => {
185+
try {
186+
setError(null);
187+
const next = await getDiditStatus();
188+
setStatus(next);
189+
} catch (e) {
190+
setError(e instanceof Error ? e.message : 'Failed to load status');
191+
} finally {
192+
setLoading(false);
193+
}
194+
}, []);
195+
196+
useEffect(() => {
197+
refresh();
198+
}, [refresh]);
199+
200+
useEffect(() => {
201+
if (status?.state !== 'in_progress') return;
202+
const id = setInterval(refresh, 10_000);
203+
return () => clearInterval(id);
204+
}, [status?.state, refresh]);
205+
206+
useEffect(() => {
207+
if (!error || status) return;
208+
// If we can't fetch authoritative status (e.g. unauthenticated)
209+
// and we don't have a usable URL state either, send the user home.
210+
if (!initialState) {
211+
router.replace('/');
212+
}
213+
}, [error, status, initialState, router]);
214+
215+
const effectiveState: VerificationState =
216+
status?.state ?? initialState ?? 'in_review';
217+
const view = VIEWS[effectiveState];
218+
const Icon = view.icon;
219+
220+
const cardBody = (() => {
221+
if (loading && !status) {
222+
return (
223+
<div className='flex flex-col items-center gap-3'>
224+
<Skeleton className='h-4 w-64' />
225+
<Skeleton className='h-4 w-48' />
226+
</div>
227+
);
228+
}
229+
if (error && !status) {
230+
return (
231+
<div className='space-y-3 text-sm text-zinc-400'>
232+
<p>
233+
We could not load the latest verification status. The page may have
234+
been opened in a session that has signed out. You can sign in again
235+
and view your status in Settings.
236+
</p>
237+
<Button
238+
type='button'
239+
variant='outline'
240+
onClick={refresh}
241+
className='w-full'
242+
>
243+
Retry
244+
</Button>
245+
</div>
246+
);
247+
}
248+
return (
249+
<p className='text-sm leading-relaxed text-zinc-400'>
250+
{status
251+
? view.body(status)
252+
: view.body({
253+
state: effectiveState,
254+
canStartNew: false,
255+
message: '',
256+
} as VerificationStatus)}
257+
</p>
258+
);
259+
})();
260+
261+
return (
262+
<main className='flex min-h-screen items-center justify-center bg-zinc-950 px-6 py-16'>
263+
<div className='relative w-full max-w-md rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8 shadow-2xl shadow-black/40'>
264+
<div className='mb-6 flex items-center justify-center'>
265+
<div className='relative flex items-center justify-center'>
266+
<span
267+
className={clsx(
268+
'absolute inline-flex h-20 w-20 animate-ping rounded-full',
269+
view.haloClass
270+
)}
271+
/>
272+
<span
273+
className={clsx(
274+
'relative flex h-16 w-16 items-center justify-center rounded-full ring-1',
275+
view.ringClass
276+
)}
277+
>
278+
<Icon className={clsx('h-8 w-8', view.iconClass)} />
279+
</span>
280+
</div>
281+
</div>
282+
283+
<h1 className='text-center text-xl font-semibold text-white'>
284+
{view.title}
285+
</h1>
286+
<div className='mt-4 text-center'>{cardBody}</div>
287+
288+
{loading && status && (
289+
<div className='mt-4 flex items-center justify-center gap-2 text-xs text-zinc-500'>
290+
<Loader2 className='h-3.5 w-3.5 animate-spin' /> Refreshing…
291+
</div>
292+
)}
293+
294+
<div className='mt-8 space-y-3'>
295+
{status?.canStartNew && view.showVerifyButton && (
296+
<DiditVerifyButton
297+
label={
298+
effectiveState === 'declined'
299+
? 'Try verification again'
300+
: effectiveState === 'expired' ||
301+
effectiveState === 'abandoned'
302+
? 'Start new verification'
303+
: 'Verify identity'
304+
}
305+
className='w-full'
306+
onError={refresh}
307+
/>
308+
)}
309+
{view.primaryHref && (
310+
<Button asChild variant='outline' className='w-full'>
311+
<Link href={view.primaryHref}>
312+
{view.primaryLabel}
313+
<ArrowRight className='ml-2 h-4 w-4' />
314+
</Link>
315+
</Button>
316+
)}
317+
</div>
318+
</div>
319+
</main>
320+
);
321+
}
322+
323+
export default function VerifyPage() {
324+
return (
325+
<Suspense
326+
fallback={
327+
<main className='flex min-h-screen items-center justify-center bg-zinc-950'>
328+
<Loader2 className='h-6 w-6 animate-spin text-zinc-500' />
329+
</main>
330+
}
331+
>
332+
<VerifyResult />
333+
</Suspense>
334+
);
335+
}

0 commit comments

Comments
 (0)