Skip to content

Commit de53046

Browse files
authored
Partner account verification via Veriff (dubinc#3614)
1 parent 8debf50 commit de53046

41 files changed

Lines changed: 1917 additions & 103 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,8 @@ HUBSPOT_CLIENT_SECRET=
188188
# E2E Playwright Tests
189189
PLAYWRIGHT_BASE_URL=http://partners.localhost:8888
190190
E2E_PARTNER_EMAIL=
191-
E2E_PARTNER_PASSWORD=
191+
E2E_PARTNER_PASSWORD=
192+
193+
# Veriff (Identity Verification)
194+
VERIFF_API_KEY=
195+
VERIFF_SHARED_SECRET=
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { withCron } from "@/lib/cron/with-cron";
2+
import { fetchVeriffSessionDecision } from "@/lib/veriff/fetch-veriff-session-decision";
3+
import {
4+
mergeVeriffMetadata,
5+
parseVeriffMetadata,
6+
} from "@/lib/veriff/veriff-metadata";
7+
import { sendEmail } from "@dub/email";
8+
import PartnerIdentityVerificationFailed from "@dub/email/templates/partner-identity-verification-failed";
9+
import PartnerIdentityVerified from "@dub/email/templates/partner-identity-verified";
10+
import { prisma } from "@dub/prisma";
11+
import { logAndRespond } from "app/(ee)/api/cron/utils";
12+
import * as z from "zod/v4";
13+
14+
export const dynamic = "force-dynamic";
15+
16+
const schema = z.object({
17+
partnerId: z.string(),
18+
});
19+
20+
// POST /api/cron/partners/verify-country-change
21+
export const POST = withCron(async ({ rawBody }) => {
22+
const { partnerId } = schema.parse(JSON.parse(rawBody));
23+
24+
const partner = await prisma.partner.findUnique({
25+
where: {
26+
id: partnerId,
27+
},
28+
select: {
29+
id: true,
30+
name: true,
31+
email: true,
32+
country: true,
33+
identityVerificationStatus: true,
34+
identityVerifiedAt: true,
35+
veriffSessionId: true,
36+
veriffMetadata: true,
37+
},
38+
});
39+
40+
if (!partner) {
41+
return logAndRespond("Partner not found.");
42+
}
43+
44+
if (!partner.veriffSessionId) {
45+
return logAndRespond("No Veriff session ID found. Skipping.");
46+
}
47+
48+
if (!partner.country) {
49+
return logAndRespond("Partner has no country set. Skipping.");
50+
}
51+
52+
// Fetch the original Veriff decision to get the document country
53+
let documentCountry: string | null = null;
54+
55+
try {
56+
const { verification } = await fetchVeriffSessionDecision(
57+
partner.veriffSessionId,
58+
);
59+
60+
documentCountry =
61+
(verification.document?.country || verification.person?.nationality) ??
62+
null;
63+
} catch (error) {
64+
console.error(
65+
`Failed to fetch Veriff decision for partner ${partnerId}:`,
66+
error,
67+
);
68+
69+
// Don't revoke on uncertainty — QStash will retry
70+
throw error;
71+
}
72+
73+
if (!documentCountry) {
74+
return logAndRespond(
75+
"Could not determine document country from Veriff decision. Skipping.",
76+
);
77+
}
78+
79+
// Compare partner's current country with the verified document country
80+
if (partner.country.toLowerCase() === documentCountry.toLowerCase()) {
81+
if (partner.identityVerifiedAt) {
82+
return logAndRespond(
83+
"Country matches and partner is already verified. No action needed.",
84+
);
85+
}
86+
await prisma.partner.update({
87+
where: {
88+
id: partner.id,
89+
},
90+
data: {
91+
identityVerifiedAt: new Date(),
92+
},
93+
});
94+
if (partner.email) {
95+
await sendEmail({
96+
to: partner.email,
97+
subject: "Your identity has been verified",
98+
react: PartnerIdentityVerified({
99+
partner: {
100+
name: partner.name,
101+
email: partner.email,
102+
},
103+
}),
104+
});
105+
}
106+
return logAndRespond("Country matches, verified partner.");
107+
} else {
108+
// Mismatch — reset identity verification, but preserve attemptCount
109+
const declineReason =
110+
"Your account country no longer matches your verified identity document country. Please re-verify.";
111+
112+
const { attemptCount } = parseVeriffMetadata(partner.veriffMetadata);
113+
114+
await prisma.partner.update({
115+
where: {
116+
id: partner.id,
117+
},
118+
data: {
119+
identityVerificationStatus: null,
120+
identityVerifiedAt: null,
121+
veriffSessionId: null,
122+
veriffMetadata: mergeVeriffMetadata(partner.veriffMetadata, {
123+
sessionUrl: null,
124+
sessionExpiresAt: null,
125+
declineReason,
126+
attemptCount,
127+
}),
128+
},
129+
});
130+
131+
if (partner.email) {
132+
await sendEmail({
133+
to: partner.email,
134+
subject: "Identity re-verification required",
135+
react: PartnerIdentityVerificationFailed({
136+
failureType: "countryChange",
137+
failureReasonText: declineReason,
138+
partner: {
139+
name: partner.name,
140+
email: partner.email,
141+
},
142+
}),
143+
});
144+
}
145+
146+
return logAndRespond(
147+
`Country mismatch detected for partner ${partnerId}. Verification reset and email sent.`,
148+
);
149+
}
150+
});

apps/web/app/(ee)/api/network/partners/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export const GET = withWorkspace(
8383
starredAt: partner.starredAt ? new Date(partner.starredAt) : null,
8484
ignoredAt: partner.ignoredAt ? new Date(partner.ignoredAt) : null,
8585
invitedAt: partner.invitedAt ? new Date(partner.invitedAt) : null,
86+
identityVerificationStatus:
87+
partner.identityVerificationStatus ?? null,
88+
identityVerifiedAt: partner.identityVerifiedAt
89+
? new Date(partner.identityVerifiedAt)
90+
: null,
8691
categories: partner.categories
8792
? partner.categories.split(",").map((c: string) => c.trim())
8893
: [],
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"use client";
2+
3+
import { parseActionError } from "@/lib/actions/parse-action-errors";
4+
import { startIdentityVerificationAction } from "@/lib/actions/partners/start-identity-verification";
5+
import { hasPermission } from "@/lib/auth/partner-users/partner-user-permissions";
6+
import usePartnerProfile from "@/lib/swr/use-partner-profile";
7+
import { PartnerProps } from "@/lib/types";
8+
import { MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS } from "@/lib/zod/schemas/partners";
9+
import { Button, StatusBadge } from "@dub/ui";
10+
import {
11+
ShieldCheck,
12+
TriangleWarning,
13+
Veriff,
14+
VerifiedBadge,
15+
} from "@dub/ui/icons";
16+
import { cn } from "@dub/utils";
17+
import { useAction } from "next-safe-action/hooks";
18+
import { toast } from "sonner";
19+
20+
export function IdentityVerificationSection({
21+
partner,
22+
}: {
23+
partner?: PartnerProps;
24+
}) {
25+
const { mutate } = usePartnerProfile();
26+
27+
const { executeAsync, isPending } = useAction(
28+
startIdentityVerificationAction,
29+
{
30+
onError: ({ error }) => {
31+
toast.error(
32+
parseActionError(error, "Failed to start identity verification."),
33+
);
34+
},
35+
onSuccess: async ({ data }) => {
36+
const { createVeriffFrame, MESSAGES } = await import(
37+
"@veriff/incontext-sdk"
38+
);
39+
40+
createVeriffFrame({
41+
url: data.sessionUrl,
42+
onEvent: (msg) => {
43+
if (msg === MESSAGES.FINISHED) {
44+
toast.success(
45+
"Verification submitted. We'll update your status shortly.",
46+
);
47+
mutate();
48+
}
49+
},
50+
});
51+
52+
mutate();
53+
},
54+
},
55+
);
56+
57+
if (!partner) {
58+
return null;
59+
}
60+
61+
const {
62+
identityVerificationStatus,
63+
identityVerificationDeclineReason,
64+
identityVerificationAttemptCount,
65+
} = partner;
66+
67+
const cannotUpdateProfile = !hasPermission(
68+
partner.role,
69+
"partner_profile.update",
70+
);
71+
72+
const isPendingReview =
73+
identityVerificationStatus === "submitted" ||
74+
identityVerificationStatus === "review";
75+
76+
const isMaxAttemptsReached =
77+
identityVerificationAttemptCount >=
78+
MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS &&
79+
identityVerificationStatus !== "approved" &&
80+
!isPendingReview;
81+
82+
const isFailed = [
83+
"declined",
84+
"resubmissionRequested",
85+
"expired",
86+
"abandoned",
87+
].includes(identityVerificationStatus || "");
88+
89+
let buttonText = "Start verification";
90+
let failedReason = identityVerificationDeclineReason || null;
91+
92+
// If the verification failed and no reason is provided, set the reason based on the status
93+
if (isFailed && failedReason === null) {
94+
switch (identityVerificationStatus) {
95+
case "declined":
96+
failedReason =
97+
"We couldn't verify your identity. Please check your information or documents and try again.";
98+
break;
99+
case "resubmissionRequested":
100+
failedReason =
101+
"Verification couldn't be completed. Please check your information and resubmit.";
102+
break;
103+
case "expired":
104+
failedReason =
105+
"Verification attempt expired. Please start a new verification";
106+
break;
107+
case "abandoned":
108+
failedReason =
109+
"Verification attempt abandoned. Please start a new verification";
110+
break;
111+
}
112+
}
113+
114+
switch (identityVerificationStatus) {
115+
case "started":
116+
buttonText = "Complete verification";
117+
break;
118+
case "declined":
119+
case "resubmissionRequested":
120+
buttonText = "Resubmit verification";
121+
break;
122+
}
123+
124+
return (
125+
<div
126+
id="identity-verification"
127+
className={cn(
128+
failedReason && "overflow-hidden rounded-lg bg-amber-100 p-1",
129+
)}
130+
>
131+
{failedReason && (
132+
<div className="flex items-center gap-2 px-2 py-2">
133+
<TriangleWarning className="size-3.5 shrink-0 text-amber-500" />
134+
<p className="leading-0 text-sm font-medium text-amber-900">
135+
<span className="font-semibold">Verification failed:</span>{" "}
136+
{failedReason}
137+
</p>
138+
</div>
139+
)}
140+
141+
<div className="border-border-subtle relative overflow-hidden rounded-lg border bg-neutral-50">
142+
<div
143+
className="pointer-events-none absolute inset-0 opacity-[0.4] [-webkit-mask-image:radial-gradient(ellipse_95%_85%_at_50%_42%,#000_0%,transparent_68%)] [background-image:radial-gradient(rgb(163_163_163)_1px,transparent_1px)] [background-size:4px_4px] [mask-image:radial-gradient(ellipse_95%_85%_at_50%_42%,#000_0%,transparent_68%)]"
144+
aria-hidden
145+
/>
146+
<div className="relative flex flex-col items-center gap-3 px-6 py-3">
147+
{identityVerificationStatus === "approved" ? (
148+
<VerifiedBadge className="size-6" />
149+
) : (
150+
<ShieldCheck className="size-6 text-neutral-400" />
151+
)}
152+
153+
{identityVerificationStatus === "approved" && (
154+
<StatusBadge
155+
variant="success"
156+
className="rounded-lg font-semibold"
157+
icon={null}
158+
>
159+
Identity verified
160+
</StatusBadge>
161+
)}
162+
163+
{isPendingReview && (
164+
<StatusBadge
165+
variant="pending"
166+
className="rounded-lg font-semibold"
167+
icon={null}
168+
>
169+
Pending review
170+
</StatusBadge>
171+
)}
172+
173+
{identityVerificationStatus !== "approved" &&
174+
!isPendingReview &&
175+
buttonText && (
176+
<Button
177+
text={buttonText}
178+
variant="secondary"
179+
disabled={isMaxAttemptsReached || cannotUpdateProfile}
180+
disabledTooltip={
181+
cannotUpdateProfile
182+
? "You don't have permission to update this field"
183+
: isMaxAttemptsReached
184+
? "You have reached the maximum number of verification attempts. Please contact support if you need help."
185+
: undefined
186+
}
187+
onClick={() => executeAsync()}
188+
loading={isPending}
189+
className="h-10 w-fit rounded-lg px-4 py-1.5"
190+
/>
191+
)}
192+
193+
<div className="flex items-center gap-1 text-xs font-medium text-neutral-400">
194+
<span>Powered by</span>
195+
<Veriff className="w-auto" />
196+
</div>
197+
</div>
198+
</div>
199+
</div>
200+
);
201+
}

apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { hasPermission } from "@/lib/auth/partner-users/partner-user-permissions
44
import usePartnerProfile from "@/lib/swr/use-partner-profile";
55
import { PageContent } from "@/ui/layout/page-content";
66
import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper";
7+
78
import { useMergePartnerAccountsModal } from "@/ui/partners/merge-accounts/merge-partner-accounts-modal";
89
import { ThreeDots } from "@/ui/shared/icons";
910
import { Button, Popover, Users2 } from "@dub/ui";

0 commit comments

Comments
 (0)