diff --git a/apps/web/.env.example b/apps/web/.env.example index 3fae905b48a..12559bc4037 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -190,4 +190,8 @@ HUBSPOT_CLIENT_SECRET= # E2E Playwright Tests PLAYWRIGHT_BASE_URL=http://partners.localhost:8888 E2E_PARTNER_EMAIL= -E2E_PARTNER_PASSWORD= \ No newline at end of file +E2E_PARTNER_PASSWORD= + +# Veriff (Identity Verification) +VERIFF_API_KEY= +VERIFF_WEBHOOK_SECRET= diff --git a/apps/web/app/(ee)/api/cron/partners/auto-approve/route.ts b/apps/web/app/(ee)/api/cron/partners/auto-approve/route.ts index 3eaa1da65d5..3dd32d91b95 100644 --- a/apps/web/app/(ee)/api/cron/partners/auto-approve/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/auto-approve/route.ts @@ -102,6 +102,8 @@ export const POST = withCron(async ({ rawBody }) => { context: { country: programEnrollment.partner.country, email: programEnrollment.partner.email, + identityVerificationStatus: + programEnrollment.partner.identityVerificationStatus, }, }); diff --git a/apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts b/apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts index 38c7f852377..80a5c342db8 100644 --- a/apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts @@ -34,6 +34,7 @@ export const POST = withCron(async ({ rawBody }) => { name: true, email: true, country: true, + identityVerificationStatus: true, }, }, program: { @@ -65,6 +66,8 @@ export const POST = withCron(async ({ rawBody }) => { context: { country: programEnrollment.partner.country, email: programEnrollment.partner.email, + identityVerificationStatus: + programEnrollment.partner.identityVerificationStatus, }, }); diff --git a/apps/web/app/(ee)/api/network/partners/route.ts b/apps/web/app/(ee)/api/network/partners/route.ts index d4c52893b76..0729ba58c0c 100644 --- a/apps/web/app/(ee)/api/network/partners/route.ts +++ b/apps/web/app/(ee)/api/network/partners/route.ts @@ -83,6 +83,11 @@ export const GET = withWorkspace( starredAt: partner.starredAt ? new Date(partner.starredAt) : null, ignoredAt: partner.ignoredAt ? new Date(partner.ignoredAt) : null, invitedAt: partner.invitedAt ? new Date(partner.invitedAt) : null, + identityVerificationStatus: + partner.identityVerificationStatus ?? null, + identityVerifiedAt: partner.identityVerifiedAt + ? new Date(partner.identityVerifiedAt) + : null, categories: partner.categories ? partner.categories.split(",").map((c: string) => c.trim()) : [], diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx index f42733cf8b5..1846ec8313d 100644 --- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx +++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx @@ -133,7 +133,7 @@ export function EmbedBountyDetail({ aria-label="Back to bounties" title="Back to bounties" onClick={onBack} - className="bg-bg-subtle flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 hover:bg-bg-emphasis active:scale-95" + className="bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95" > diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx index d09de02a10a..7f03544c5f6 100644 --- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx +++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx @@ -41,7 +41,7 @@ export function EmbedBountySubmissionDetail({ aria-label="Back to bounties" title="Back to bounties" onClick={onBackToRoot} - className="bg-bg-subtle flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 hover:bg-bg-emphasis active:scale-95" + className="bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95" > diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx new file mode 100644 index 00000000000..b143a24484b --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { parseActionError } from "@/lib/actions/parse-action-errors"; +import { startIdentityVerificationAction } from "@/lib/actions/partners/start-identity-verification"; +import usePartnerProfile from "@/lib/swr/use-partner-profile"; +import { PartnerProps } from "@/lib/types"; +import { MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS } from "@/lib/zod/schemas/partners"; +import { Button, StatusBadge } from "@dub/ui"; +import { + ShieldCheck, + TriangleWarning, + Veriff, + VerifiedBadge, +} from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { useAction } from "next-safe-action/hooks"; +import { toast } from "sonner"; + +export function IdentityVerificationSection({ + partner, +}: { + partner?: PartnerProps; +}) { + const { mutate } = usePartnerProfile(); + + const { executeAsync, isPending } = useAction( + startIdentityVerificationAction, + { + onError: ({ error }) => { + toast.error( + parseActionError(error, "Failed to start identity verification."), + ); + }, + onSuccess: async ({ data }) => { + const { createVeriffFrame, MESSAGES } = await import( + "@veriff/incontext-sdk" + ); + + createVeriffFrame({ + url: data.sessionUrl, + onEvent: (msg) => { + if (msg === MESSAGES.FINISHED) { + toast.success( + "Verification submitted. We'll update your status shortly.", + ); + mutate(); + } + }, + }); + + mutate(); + }, + }, + ); + + if (!partner) { + return null; + } + + const { + identityVerificationStatus, + identityVerificationDeclineReason, + identityVerificationAttemptCount, + } = partner; + + const isPendingReview = + identityVerificationStatus === "submitted" || + identityVerificationStatus === "review"; + + const isMaxAttemptsReached = + identityVerificationAttemptCount >= + MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS && + identityVerificationStatus !== "approved" && + !isPendingReview; + + const isFailed = [ + "declined", + "resubmissionRequested", + "expired", + "abandoned", + ].includes(identityVerificationStatus || ""); + + let buttonText = "Start verification"; + let failedReason = identityVerificationDeclineReason || null; + + // If the verification failed and no reason is provided, set the reason based on the status + if (isFailed && failedReason === null) { + switch (identityVerificationStatus) { + case "declined": + failedReason = + "We couldn't verify your identity. Please check your information or documents and try again."; + break; + case "resubmissionRequested": + failedReason = + "Verification couldn't be completed. Please check your information and resubmit."; + break; + case "expired": + failedReason = + "Verification attempt expired. Please start a new verification"; + break; + case "abandoned": + failedReason = + "Verification attempt abandoned. Please start a new verification"; + break; + } + } + + switch (identityVerificationStatus) { + case "started": + buttonText = "Complete verification"; + break; + case "declined": + case "resubmissionRequested": + buttonText = "Resubmit verification"; + break; + } + + return ( +
+ {failedReason && ( +
+ +

+ Verification failed:{" "} + {failedReason} +

+
+ )} + +
+
+
+ {identityVerificationStatus === "approved" ? ( + + ) : ( + + )} + + {identityVerificationStatus === "approved" ? ( + + Identity verified + + ) : isPendingReview ? ( + + Pending review + + ) : buttonText ? ( +
+
+
+ ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx index c882dc27743..d3e64cac7f4 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx @@ -4,6 +4,7 @@ import { hasPermission } from "@/lib/auth/partner-users/partner-user-permissions import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { PageContent } from "@/ui/layout/page-content"; import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; + import { useMergePartnerAccountsModal } from "@/ui/partners/merge-accounts/merge-partner-accounts-modal"; import { ThreeDots } from "@/ui/shared/icons"; import { Button, Popover, Users2 } from "@dub/ui"; diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx index 191c593f385..6caae9ccead 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx @@ -32,6 +32,7 @@ import { useFormContext, } from "react-hook-form"; import { toast } from "sonner"; +import { IdentityVerificationSection } from "./identity-verification-section"; import { SettingsRow } from "./settings-row"; type BasicInfoFormData = { @@ -102,6 +103,14 @@ export function ProfileDetailsForm({ partner }: { partner?: PartnerProps }) { + + + + ; + +const veriffStatusMap: Record< + VeriffDecisionEvent["verification"]["status"], + IdentityVerificationStatus +> = { + approved: "approved", + declined: "declined", + expired: "expired", + abandoned: "abandoned", + review: "review", + resubmission_requested: "resubmissionRequested", +}; + +export const handleDecisionEvent = async ({ + verification, +}: VeriffDecisionEvent) => { + const { id, status, decisionTime, reason, attemptId } = verification; + + let effectiveStatus = status; + let effectiveReason = reason || null; + + const partner = await prisma.partner.findUnique({ + where: { + veriffSessionId: id, + }, + select: { + id: true, + name: true, + email: true, + country: true, + identityVerifiedAt: true, + }, + }); + + if (!partner) { + return logAndRespond("[Veriff Webhook] No partner found for session."); + } + + if (partner.identityVerifiedAt) { + return logAndRespond("[Veriff Webhook] Partner already verified."); + } + + const toUpdate: Prisma.PartnerUpdateInput = {}; + + // If the verification was approved, compute the identity hash and check for duplicates and country mismatch + if (effectiveStatus === "approved") { + const identityHash = computeIdentityHash(verification); + + const isDuplicate = await checkDuplicateIdentity({ + partner, + identityHash, + }); + + const isCountryMismatch = checkCountryMismatch({ + partner, + verification, + }); + + const checkPassed = !isDuplicate && !isCountryMismatch; + + if (checkPassed) { + toUpdate.identityVerificationDeclineReason = null; + toUpdate.veriffIdentityHash = identityHash; + toUpdate.veriffSessionExpiresAt = null; + toUpdate.veriffSessionUrl = null; + toUpdate.veriffSessionId = null; + toUpdate.identityVerifiedAt = decisionTime + ? new Date(decisionTime) + : new Date(); + } else { + effectiveStatus = "declined"; + + if (isDuplicate) { + effectiveReason = + "This identity has already been verified on another account"; + } else if (isCountryMismatch) { + effectiveReason = + "Your document country does not match your account country"; + } + } + } + + // If the verification failed, reset the session + if (["expired", "abandoned", "declined"].includes(effectiveStatus)) { + toUpdate.identityVerifiedAt = null; + toUpdate.veriffSessionExpiresAt = null; + toUpdate.veriffSessionUrl = null; + toUpdate.veriffSessionId = null; + toUpdate.identityVerificationDeclineReason = effectiveReason; + } + + // Can reuse the same session for resubmission + if (effectiveStatus === "resubmission_requested") { + toUpdate.identityVerifiedAt = null; + toUpdate.identityVerificationDeclineReason = effectiveReason; + } + + if (["approved", "declined"].includes(effectiveStatus)) { + toUpdate.identityVerificationAttemptCount = { + increment: 1, + }; + } + + toUpdate.identityVerificationStatus = veriffStatusMap[effectiveStatus]; + + await prisma.partner.update({ + where: { + id: partner.id, + identityVerifiedAt: null, + }, + data: toUpdate, + }); + + if (partner.email) { + if (effectiveStatus === "approved") { + await sendEmail({ + to: partner.email, + subject: "Your identity has been verified", + headers: { + "Idempotency-Key": `${attemptId}-verified`, + }, + react: PartnerIdentityVerified({ + partner: { + name: partner.name, + email: partner.email, + }, + }), + }); + } else { + await sendEmail({ + to: partner.email, + subject: "Your identity verification was declined", + headers: { + "Idempotency-Key": `${attemptId}-verification-failed`, + }, + react: PartnerIdentityVerificationFailed({ + partner: { + name: partner.name, + email: partner.email, + identityVerificationDeclineReason: effectiveReason || "", + }, + }), + }); + } + } + + return logAndRespond("[Veriff Webhook] Decision event handled successfully."); +}; + +async function checkDuplicateIdentity({ + partner, + identityHash, +}: { + partner: Pick; + identityHash: string | null; +}): Promise { + if (!identityHash) { + return false; + } + + const duplicatePartner = await prisma.partner.findFirst({ + where: { + veriffIdentityHash: identityHash, + id: { + not: partner.id, + }, + }, + select: { + id: true, + }, + }); + + return duplicatePartner ? true : false; +} + +function checkCountryMismatch({ + partner, + verification, +}: { + partner: Pick; + verification: VeriffDecisionEvent["verification"]; +}): boolean { + const veriffCountry = ( + verification.document?.country || verification.person?.nationality + )?.toUpperCase(); + + if (!veriffCountry || !partner.country) { + return true; + } + + return partner.country.toUpperCase() !== veriffCountry; +} + +function computeIdentityHash( + verification: VeriffDecisionEvent["verification"], +) { + const { person, document } = verification; + + // Prefer document number (passport/ID number) — strongest unique signal + if (document?.number) { + const input = [ + "doc", + document.number.toLowerCase().trim(), + document.country?.toUpperCase().trim() ?? "", + ].join("|"); + + return createHash("sha256").update(input).digest("hex"); + } + + // Fall back to name + date of birth + if (person?.firstName && person?.lastName && person?.dateOfBirth) { + const input = [ + "person", + person.firstName.toLowerCase().trim(), + person.lastName.toLowerCase().trim(), + person.dateOfBirth.trim(), + ].join("|"); + + return createHash("sha256").update(input).digest("hex"); + } + + return null; +} diff --git a/apps/web/app/api/veriff/webhook/handle-session-event.ts b/apps/web/app/api/veriff/webhook/handle-session-event.ts new file mode 100644 index 00000000000..22dd0c237a7 --- /dev/null +++ b/apps/web/app/api/veriff/webhook/handle-session-event.ts @@ -0,0 +1,43 @@ +import { veriffSessionEventSchema } from "@/lib/veriff/schema"; +import { prisma } from "@dub/prisma"; +import { logAndRespond } from "app/(ee)/api/cron/utils"; +import * as z from "zod/v4"; + +type VeriffSessionEvent = z.infer; + +export const handleSessionEvent = async ({ + id, + action, +}: VeriffSessionEvent) => { + const partner = await prisma.partner.findUnique({ + where: { + veriffSessionId: id, + }, + select: { + id: true, + identityVerifiedAt: true, + }, + }); + + if (!partner) { + return logAndRespond("[Veriff Webhook] No partner found for session."); + } + + if (partner.identityVerifiedAt) { + return logAndRespond("[Veriff Webhook] Partner already verified."); + } + + await prisma.partner.update({ + where: { + id: partner.id, + identityVerifiedAt: null, + }, + data: { + identityVerificationStatus: action, + identityVerificationDeclineReason: null, + veriffIdentityHash: null, + }, + }); + + return logAndRespond("[Veriff Webhook] Session event handled."); +}; diff --git a/apps/web/app/api/veriff/webhook/route.ts b/apps/web/app/api/veriff/webhook/route.ts new file mode 100644 index 00000000000..687e737c427 --- /dev/null +++ b/apps/web/app/api/veriff/webhook/route.ts @@ -0,0 +1,65 @@ +import { logAndRespond } from "app/(ee)/api/cron/utils"; +import crypto from "crypto"; +import { handleDecisionEvent } from "./handle-decision-event"; +import { handleSessionEvent } from "./handle-session-event"; + +// POST /api/veriff/webhook +export const POST = async (req: Request) => { + const rawBody = await req.text(); + + const signature = req.headers.get("x-hmac-signature"); + const authClient = req.headers.get("x-auth-client"); + const webhookSecret = process.env.VERIFF_WEBHOOK_SECRET; + + if (!signature) { + return logAndRespond("No signature provided.", { status: 401 }); + } + + const expectedApiKey = process.env.VERIFF_API_KEY; + + if (!expectedApiKey || !authClient) { + return logAndRespond("Invalid auth client.", { status: 401 }); + } + + const authClientBuffer = Uint8Array.from(Buffer.from(authClient)); + const expectedApiKeyBuffer = Uint8Array.from(Buffer.from(expectedApiKey)); + + if ( + authClientBuffer.length !== expectedApiKeyBuffer.length || + !crypto.timingSafeEqual(authClientBuffer, expectedApiKeyBuffer) + ) { + return logAndRespond("Invalid auth client.", { status: 401 }); + } + + if (!webhookSecret) { + return logAndRespond("VERIFF_WEBHOOK_SECRET is not configured.", { + status: 500, + }); + } + + const computedSignature = crypto + .createHmac("sha256", webhookSecret) + .update(rawBody) + .digest("hex"); + + const computedSignatureBuffer = Uint8Array.from( + Buffer.from(computedSignature), + ); + const signatureBuffer = Uint8Array.from(Buffer.from(signature)); + + const isSignatureValid = + computedSignatureBuffer.length === signatureBuffer.length && + crypto.timingSafeEqual(computedSignatureBuffer, signatureBuffer); + + if (!isSignatureValid) { + return logAndRespond("Invalid signature.", { status: 400 }); + } + + const body = JSON.parse(rawBody); + + if ("verification" in body) { + return await handleDecisionEvent(body); + } else { + return await handleSessionEvent(body); + } +}; diff --git a/apps/web/lib/actions/check-account-exists.ts b/apps/web/lib/actions/check-account-exists.ts index bca10da47d3..c72b0e528ed 100644 --- a/apps/web/lib/actions/check-account-exists.ts +++ b/apps/web/lib/actions/check-account-exists.ts @@ -4,7 +4,7 @@ import { getIP } from "@/lib/api/utils/get-ip"; import { ratelimit } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; -import { skipAuthThrottling } from "../api/environment"; +import { shouldApplyRateLimit } from "../api/environment"; import { isSamlEnforcedForEmailDomain } from "../api/workspaces/is-saml-enforced-for-email-domain"; import { emailSchema } from "../zod/schemas/auth"; import { throwIfAuthenticated } from "./auth/throw-if-authenticated"; @@ -21,7 +21,7 @@ export const checkAccountExistsAction = actionClient .action(async ({ parsedInput }) => { const { email } = parsedInput; - if (!skipAuthThrottling) { + if (shouldApplyRateLimit) { const { success } = await ratelimit(8, "1 m").limit( `account-exists:${await getIP()}`, ); diff --git a/apps/web/lib/actions/create-user-account.ts b/apps/web/lib/actions/create-user-account.ts index 88fcc73c9dc..45849dd96c1 100644 --- a/apps/web/lib/actions/create-user-account.ts +++ b/apps/web/lib/actions/create-user-account.ts @@ -6,7 +6,7 @@ import { waitUntil } from "@vercel/functions"; import { flattenValidationErrors } from "next-safe-action"; import * as z from "zod/v4"; import { createId } from "../api/create-id"; -import { skipAuthThrottling } from "../api/environment"; +import { shouldApplyRateLimit } from "../api/environment"; import { hashPassword } from "../auth/password"; import { signUpSchema } from "../zod/schemas/auth"; import { throwIfAuthenticated } from "./auth/throw-if-authenticated"; @@ -31,7 +31,7 @@ export const createUserAccountAction = actionClient const signupAttemptKey = `signup:attempts:${email}`; - if (!skipAuthThrottling) { + if (shouldApplyRateLimit) { const { remaining: attemptsRemaining } = await ratelimit( MAX_OTP_ATTEMPTS, OTP_LOCKOUT_DURATION, diff --git a/apps/web/lib/actions/partners/create-program-application.ts b/apps/web/lib/actions/partners/create-program-application.ts index 5f301a4e729..3a9c82f0308 100644 --- a/apps/web/lib/actions/partners/create-program-application.ts +++ b/apps/web/lib/actions/partners/create-program-application.ts @@ -258,6 +258,7 @@ async function createApplicationAndEnrollment({ // Always use the partner's country from their profile, if available country: partner.country ?? sanitizedData.country, email: partner.email, + identityVerificationStatus: partner.identityVerificationStatus, }, }); diff --git a/apps/web/lib/actions/partners/start-identity-verification.ts b/apps/web/lib/actions/partners/start-identity-verification.ts new file mode 100644 index 00000000000..2a17992291f --- /dev/null +++ b/apps/web/lib/actions/partners/start-identity-verification.ts @@ -0,0 +1,139 @@ +"use server"; + +import { shouldApplyRateLimit } from "@/lib/api/environment"; +import { ratelimit } from "@/lib/upstash/ratelimit"; +import { + veriffCreateSessionInputSchema, + veriffCreateSessionOutputSchema, +} from "@/lib/veriff/schema"; +import { MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS } from "@/lib/zod/schemas/partners"; +import { prisma } from "@dub/prisma"; +import { Partner } from "@dub/prisma/client"; +import { addDays } from "date-fns/addDays"; +import { authPartnerActionClient } from "../safe-action"; + +export const startIdentityVerificationAction = authPartnerActionClient.action( + async ({ ctx }) => { + const { partner } = ctx; + + if (partner.identityVerificationStatus) { + switch (partner.identityVerificationStatus) { + case "approved": + throw new Error( + "Your identity has already been verified. No further action is required.", + ); + case "submitted": + case "review": + throw new Error( + "A verification attempt is already in progress. Please wait for it to complete or resubmit.", + ); + } + } + + if ( + partner.identityVerificationAttemptCount >= + MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS + ) { + throw new Error( + "You've reached the maximum number of identity verification attempts. Please contact support if you need help.", + ); + } + + // If the session is already created and not expired, return the existing session + // this is to avoid creating duplicate sessions + if ( + partner.veriffSessionId && + partner.veriffSessionUrl && + partner.veriffSessionExpiresAt && + partner.veriffSessionExpiresAt > new Date() + ) { + return { + sessionUrl: partner.veriffSessionUrl, + }; + } + + // Rate limit check + if (shouldApplyRateLimit) { + const { success } = await ratelimit(1, "1 h").limit( + `identityVerification:${partner.id}`, + ); + + if (!success) { + throw new Error( + "Too many verification attempts. Please try again later.", + ); + } + } + + // Create a new session + const { verification } = await createVeriffSession({ + partner, + }); + + await prisma.partner.update({ + where: { + id: partner.id, + }, + data: { + veriffSessionId: verification.id, + veriffSessionUrl: verification.url, + veriffSessionExpiresAt: addDays(new Date(), 7), + }, + }); + + return { + sessionUrl: verification.url, + }; + }, +); + +async function createVeriffSession({ + partner, +}: { + partner: Pick; +}) { + const apiKey = process.env.VERIFF_API_KEY; + + if (!apiKey) { + throw new Error("VERIFF_API_KEY is not configured."); + } + + const nameParts = partner.name.split(" "); + const firstName = nameParts[0] || partner.name; + const lastName = nameParts.slice(1).join(" ") || partner.name; + + const input = veriffCreateSessionInputSchema.parse({ + verification: { + vendorData: partner.id, + person: { + firstName, + lastName, + }, + }, + }); + + const rawResponse = await fetch("https://stationapi.veriff.com/v1/sessions", { + method: "POST", + headers: { + "X-AUTH-CLIENT": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + + const response = await rawResponse.json(); + + if (!rawResponse.ok) { + console.error("[Veriff] Error", rawResponse); + throw new Error("Failed to create verification session."); + } + + const parsedResponse = veriffCreateSessionOutputSchema.safeParse(response); + + if (!parsedResponse.success) { + console.error("[Veriff] Invalid response", parsedResponse.error); + throw new Error("Failed to create verification session."); + } + + return parsedResponse.data; +} diff --git a/apps/web/lib/actions/partners/update-application-settings.ts b/apps/web/lib/actions/partners/update-application-settings.ts index 4a34e37cefe..e5a935785f1 100644 --- a/apps/web/lib/actions/partners/update-application-settings.ts +++ b/apps/web/lib/actions/partners/update-application-settings.ts @@ -1,22 +1,13 @@ "use server"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { applicationRequirementsSchema } from "@/lib/zod/schemas/programs"; +import { updateApplicationSettingsSchema } from "@/lib/zod/schemas/programs"; import { prisma } from "@dub/prisma"; -import { Category } from "@dub/prisma/client"; -import * as z from "zod/v4"; import { authActionClient } from "../safe-action"; import { throwIfNoPermission } from "../throw-if-no-permission"; -const schema = z.object({ - workspaceId: z.string(), - description: z.string().optional(), - categories: z.array(z.enum(Category)).optional(), - eligibilityConditions: applicationRequirementsSchema.optional(), -}); - export const updateApplicationSettingsAction = authActionClient - .inputSchema(schema) + .inputSchema(updateApplicationSettingsSchema) .action(async ({ parsedInput, ctx }) => { const { workspace } = ctx; const { description, categories, eligibilityConditions } = parsedInput; @@ -28,7 +19,7 @@ export const updateApplicationSettingsAction = authActionClient const programId = getDefaultProgramIdOrThrow(workspace); - const program = await prisma.program.update({ + await prisma.program.update({ where: { id: programId, }, @@ -45,6 +36,4 @@ export const updateApplicationSettingsAction = authActionClient }), }, }); - - return program; }); diff --git a/apps/web/lib/api/environment.ts b/apps/web/lib/api/environment.ts index 495778b7385..d1f02685526 100644 --- a/apps/web/lib/api/environment.ts +++ b/apps/web/lib/api/environment.ts @@ -2,4 +2,4 @@ export const isProduction = process.env.NODE_ENV === "production"; export const isLocalDev = process.env.NODE_ENV === "development"; export const isCI = process.env.CI === "true"; -export const skipAuthThrottling = isCI || isLocalDev; +export const shouldApplyRateLimit = !(isCI || isLocalDev); diff --git a/apps/web/lib/auth/options.ts b/apps/web/lib/auth/options.ts index 7eeeed7243b..ace8024fc49 100644 --- a/apps/web/lib/auth/options.ts +++ b/apps/web/lib/auth/options.ts @@ -18,7 +18,7 @@ import EmailProvider from "next-auth/providers/email"; import GithubProvider from "next-auth/providers/github"; import GoogleProvider from "next-auth/providers/google"; import { createId } from "../api/create-id"; -import { isProduction, skipAuthThrottling } from "../api/environment"; +import { isProduction, shouldApplyRateLimit } from "../api/environment"; import { isSamlEnforcedForEmailDomain } from "../api/workspaces/is-saml-enforced-for-email-domain"; import { qstash } from "../cron"; import { completeProgramApplications } from "../partners/complete-program-applications"; @@ -223,7 +223,7 @@ export const authOptions: NextAuthOptions = { throw new Error("no-credentials"); } - if (!skipAuthThrottling) { + if (shouldApplyRateLimit) { const { success } = await ratelimit(5, "1 m").limit( `login-attempts:${email}`, ); diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index e80ae2f2f73..e27f7a6d555 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -223,6 +223,9 @@ export const withPartnerProfile = ( salesChannels: true, platforms: true, }, + omit: { + veriffIdentityHash: true, + }, }, }, }); diff --git a/apps/web/lib/partners/complete-program-applications.ts b/apps/web/lib/partners/complete-program-applications.ts index 909a80ab9b4..7068f5af218 100644 --- a/apps/web/lib/partners/complete-program-applications.ts +++ b/apps/web/lib/partners/complete-program-applications.ts @@ -179,6 +179,7 @@ export async function completeProgramApplications(userEmail: string) { context: { country: partner.country, email: partner.email, + identityVerificationStatus: partner.identityVerificationStatus, }, }); diff --git a/apps/web/lib/partners/evaluate-application-requirements.ts b/apps/web/lib/partners/evaluate-application-requirements.ts index f6b128ea5cc..b8f062c58e5 100644 --- a/apps/web/lib/partners/evaluate-application-requirements.ts +++ b/apps/web/lib/partners/evaluate-application-requirements.ts @@ -1,9 +1,10 @@ +import { IdentityVerificationStatus } from "@dub/prisma/client"; import { EligibilityConditionDB } from "../types"; import { applicationRequirementsSchema } from "../zod/schemas/programs"; interface Context { country?: string | null; - email?: string | null; + identityVerificationStatus?: IdentityVerificationStatus | null; } interface Result { @@ -15,33 +16,6 @@ interface Result { | "requirementsNotMet"; } -// valid: @domain.com, @*.edu, @*.acme.com, @sub.domain.co.uk -// wildcard: @*.tld e.g. @*.edu, @*.acme.com -// exact: @+tld e.g. @acme.com, @mail.acme.com -const DOMAIN_PATTERN = - /^@(\*\.([a-z0-9][a-z0-9-]*\.)*[a-z]{2,}|[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*\.[a-z]{2,})$/i; - -export function isValidDomainPattern(v: string): boolean { - return DOMAIN_PATTERN.test(v.trim()); -} - -function getEmailDomain(email: string): string { - const parts = email.split("@"); - return parts.length === 2 ? `@${parts[1].toLowerCase()}` : ""; -} - -function emailMatchesPattern(email: string, pattern: string): boolean { - const domain = getEmailDomain(email); - if (!domain) return false; - - if (pattern.startsWith("@*")) { - const suffix = pattern.slice(2); - return domain.endsWith(suffix); - } - - return domain === pattern; -} - export function evaluateApplicationRequirements({ applicationRequirements, context, @@ -113,15 +87,8 @@ export function evaluateCondition({ break; } - case "emailDomain": { - if (!context.email) { - return false; - } - - matches = condition.value.some((pattern) => - emailMatchesPattern(context.email!, pattern), - ); - + case "identityVerificationStatus": { + matches = context.identityVerificationStatus === "approved"; break; } diff --git a/apps/web/lib/veriff/schema.ts b/apps/web/lib/veriff/schema.ts new file mode 100644 index 00000000000..3d1b8f3124b --- /dev/null +++ b/apps/web/lib/veriff/schema.ts @@ -0,0 +1,73 @@ +import * as z from "zod/v4"; + +// POST /v1/sessions request body +export const veriffCreateSessionInputSchema = z.object({ + verification: z.object({ + vendorData: z.string(), + person: z.object({ + firstName: z.string(), + lastName: z.string(), + }), + }), +}); + +// POST /v1/sessions response +export const veriffCreateSessionOutputSchema = z.object({ + status: z.enum(["success"]), + verification: z.object({ + id: z.string(), + url: z.string(), + host: z.string(), + status: z.string(), + sessionToken: z.string(), + }), +}); + +// Session webhook payload +export const veriffSessionEventSchema = z.object({ + id: z.string(), + code: z.number(), + vendorData: z.string(), + action: z.enum(["started", "submitted"]), +}); + +// Decision webhook payload +export const veriffDecisionEventSchema = z.object({ + verification: z.object({ + id: z.string(), + vendorData: z.string(), + decisionTime: z.string().nullable(), + attemptId: z.string(), + status: z.enum([ + "approved", + "declined", + "expired", + "resubmission_requested", + "abandoned", + "review", + ]), + reason: z.string().optional(), + reasonCode: z.number().optional(), + person: z + .object({ + firstName: z.string().optional(), + lastName: z.string().optional(), + dateOfBirth: z.string().optional(), + nationality: z.string().optional(), + idNumber: z.string().optional(), + }) + .optional(), + document: z + .object({ + number: z.string().optional(), + type: z.string().optional(), + country: z.string().optional(), + }) + .optional(), + }), +}); + +export const veriffEventSchema = z.union([ + veriffSessionEventSchema, + veriffDecisionEventSchema, +]); diff --git a/apps/web/lib/zod/schemas/partner-network.ts b/apps/web/lib/zod/schemas/partner-network.ts index c3b1f823702..bc56c736602 100644 --- a/apps/web/lib/zod/schemas/partner-network.ts +++ b/apps/web/lib/zod/schemas/partner-network.ts @@ -78,6 +78,8 @@ export const NetworkPartnerSchema = PartnerSchema.pick({ monthlyTraffic: true, preferredEarningStructures: true, salesChannels: true, + identityVerificationStatus: true, + identityVerifiedAt: true, }).extend({ lastConversionAt: z.date().nullable(), conversionScore: PartnerConversionScoreSchema, diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 3b6fd97f8dc..b0193e03777 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -1,5 +1,6 @@ import { MAX_PARTNERS_INVITES_PER_REQUEST } from "@/lib/constants/program"; import { + IdentityVerificationStatus, IndustryInterest, MonthlyTraffic, PartnerBannedReason, @@ -27,6 +28,9 @@ import { ProgramEnrollmentSchema } from "./programs"; import { centsSchema, centsSchemaWithDefault, parseUrlSchema } from "./utils"; export const PARTNERS_MAX_PAGE_SIZE = 100; +export const MAX_PARTNER_INDUSTRY_INTERESTS = 8; +export const MAX_PARTNER_DESCRIPTION_LENGTH = 500; +export const MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS = 2; export const ACTIVE_ENROLLMENT_STATUSES: ProgramEnrollmentStatus[] = [ ProgramEnrollmentStatus.approved, @@ -312,8 +316,6 @@ export const PartnerPartnerPlatformsSchema = z.object({ .describe("The partner's TikTok username (e.g. `johndoe`)."), }); -export const MAX_PARTNER_INDUSTRY_INTERESTS = 8; - export const PartnerProfileSchema = z.object({ monthlyTraffic: z .enum(MonthlyTraffic) @@ -340,8 +342,6 @@ export const PartnerProfileSchema = z.object({ .describe("The partner's sales channels."), }); -export const MAX_PARTNER_DESCRIPTION_LENGTH = 500; - export const PartnerSchema = z .object({ id: z.string().describe("The partner's unique ID on Dub."), @@ -421,6 +421,25 @@ export const PartnerSchema = z .describe( "The date when the partner received the trusted badge in the partner network.", ), + identityVerificationStatus: z + .enum(IdentityVerificationStatus) + .nullable() + .describe( + "The partner's identity verification status. Null means not yet initiated.", + ), + identityVerificationAttemptCount: z + .number() + .describe( + "The number of identity verification attempts started by the partner.", + ), + identityVerificationDeclineReason: z + .string() + .nullable() + .describe("The reason for the partner's identity verification decline."), + identityVerifiedAt: z + .date() + .nullable() + .describe("The date when the partner's identity was verified."), }) .extend(PartnerPartnerPlatformsSchema.shape) .extend(PartnerProfileSchema.partial().shape); @@ -457,6 +476,8 @@ export const EnrolledPartnerSchema = PartnerSchema.pick({ stripeConnectId: true, payoutsEnabledAt: true, trustedAt: true, + identityVerificationStatus: true, + identityVerifiedAt: true, }) .extend( ProgramEnrollmentSchema.omit({ @@ -555,6 +576,8 @@ export const EnrolledPartnerSchemaExtended = EnrolledPartnerSchema.extend({ industryInterests: true, preferredEarningStructures: true, salesChannels: true, + identityVerificationStatus: true, + identityVerifiedAt: true, }).shape, ) .extend(PartnerPartnerPlatformsSchema.shape); diff --git a/apps/web/lib/zod/schemas/programs.ts b/apps/web/lib/zod/schemas/programs.ts index 6d84a037999..8d736e7ff22 100644 --- a/apps/web/lib/zod/schemas/programs.ts +++ b/apps/web/lib/zod/schemas/programs.ts @@ -25,30 +25,22 @@ import { RewardSchema } from "./rewards"; import { UserSchema } from "./users"; import { centsSchemaWithDefault, parseDateSchema } from "./utils"; -export const eligibilityConditionSchema = z - .object({ - key: z.enum(["country", "emailDomain"]), - operator: z.enum(["is", "is_not"]), - value: z.array(z.string()).min(1), - }) - .transform((data) => { - if (data.key === "emailDomain") { - return { - ...data, - value: data.value.map((v) => { - const t = v.trim().toLowerCase(); - return t.startsWith("@") ? t : `@${t}`; - }), - }; - } - return data; - }) - .refine( - (data) => - data.key !== "emailDomain" || - data.value.every((v) => v.length > 1 && v !== "@"), - { message: "Email domain values must be valid domain patterns" }, - ); +const countryEligibilityConditionSchema = z.object({ + key: z.literal("country"), + operator: z.enum(["is", "is_not"]), + value: z.array(z.string()).min(1), +}); + +const identityVerificationStatusEligibilityConditionSchema = z.object({ + key: z.literal("identityVerificationStatus"), + operator: z.literal("is"), + value: z.literal("approved"), +}); + +export const eligibilityConditionSchema = z.union([ + countryEligibilityConditionSchema, + identityVerificationStatusEligibilityConditionSchema, +]); export const applicationRequirementsSchema = z .array(eligibilityConditionSchema) @@ -253,3 +245,10 @@ export const deletePartnerCommentSchema = z.object({ workspaceId: z.string(), commentId: z.string(), }); + +export const updateApplicationSettingsSchema = z.object({ + workspaceId: z.string(), + description: z.string().optional(), + categories: z.array(z.enum(Category)).optional(), + eligibilityConditions: applicationRequirementsSchema.optional(), +}); diff --git a/apps/web/package.json b/apps/web/package.json index d095a54c628..9d8263e0dd6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,6 +64,7 @@ "@vercel/edge-config": "^0.4.1", "@vercel/functions": "^3.4.3", "@vercel/og": "^0.6.5", + "@veriff/incontext-sdk": "^2.5.0", "@visx/curve": "^3.3.0", "@visx/geo": "^2.10.0", "@visx/gradient": "^3.3.0", @@ -141,6 +142,7 @@ "zod-openapi": "5.4.6" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/he": "^1.2.3", "@types/html-escaper": "^3.0.0", "@types/luxon": "^3.7.1", @@ -162,7 +164,6 @@ "typescript": "^5.4.4", "vite": "7.2.2", "vite-tsconfig-paths": "5.1.4", - "@playwright/test": "^1.52.0", "vitest": "4.0.8" }, "browser": { diff --git a/apps/web/tests/misc/check-eligibility-requirements.test.ts b/apps/web/tests/misc/check-eligibility-requirements.test.ts index 08264025792..202fee6bab8 100644 --- a/apps/web/tests/misc/check-eligibility-requirements.test.ts +++ b/apps/web/tests/misc/check-eligibility-requirements.test.ts @@ -1,9 +1,13 @@ import { evaluateApplicationRequirements } from "@/lib/partners/evaluate-application-requirements"; +import { IdentityVerificationStatus } from "@dub/prisma/client"; import { describe, expect, it } from "vitest"; function evaluate( applicationRequirements: unknown, - context: { country?: string | null; email?: string | null }, + context: { + country?: string | null; + identityVerificationStatus?: IdentityVerificationStatus | null; + }, ) { return evaluateApplicationRequirements({ applicationRequirements, context }); } @@ -59,134 +63,23 @@ describe("evaluateApplicationRequirements", () => { }); }); - describe("emailDomain — is (exact match)", () => { - const condition = { - key: "emailDomain" as const, - operator: "is" as const, - value: ["@acme.com"], - }; - - it("returns valid when domain matches exactly", () => { - const result = evaluate([condition], { email: "jane@acme.com" }); - expect(result.valid).toBe(true); - expect(result.reason).toBe("requirementsMet"); - }); - - it("returns invalid for a subdomain — exact match is strict", () => { - const result = evaluate([condition], { email: "jane@sub.acme.com" }); - expect(result.valid).toBe(false); - expect(result.reason).toBe("requirementsNotMet"); - }); - - it("returns invalid when domain contains the pattern as a suffix but is a different domain", () => { - const result = evaluate([condition], { email: "jane@notacme.com" }); - expect(result.valid).toBe(false); - expect(result.reason).toBe("requirementsNotMet"); - }); - }); - - describe("emailDomain — is (wildcard)", () => { - it("@*.edu matches any .edu email", () => { - const condition = { - key: "emailDomain" as const, - operator: "is" as const, - value: ["@*.edu"], - }; - const resultMatch = evaluate([condition], { email: "jane@mit.edu" }); - expect(resultMatch.valid).toBe(true); - expect(resultMatch.reason).toBe("requirementsMet"); - - const resultNoMatch = evaluate([condition], { email: "jane@mit.edu.uk" }); - expect(resultNoMatch.valid).toBe(false); - expect(resultNoMatch.reason).toBe("requirementsNotMet"); - }); - - it("@*.acme.com matches subdomains but not the root domain", () => { - const condition = { - key: "emailDomain" as const, - operator: "is" as const, - value: ["@*.acme.com"], - }; - const resultMatch = evaluate([condition], { - email: "jane@mail.acme.com", - }); - expect(resultMatch.valid).toBe(true); - expect(resultMatch.reason).toBe("requirementsMet"); - - const resultNoMatch = evaluate([condition], { email: "jane@acme.com" }); - expect(resultNoMatch.valid).toBe(false); - expect(resultNoMatch.reason).toBe("requirementsNotMet"); - }); - }); - - describe("emailDomain — is_not", () => { - const condition = { - key: "emailDomain" as const, - operator: "is_not" as const, - value: ["@gmail.com"], - }; - - it("returns invalid when domain matches, valid when it does not", () => { - const resultMatch = evaluate([condition], { email: "jane@gmail.com" }); - expect(resultMatch.valid).toBe(false); - expect(resultMatch.reason).toBe("requirementsNotMet"); - - const resultNoMatch = evaluate([condition], { email: "jane@acme.com" }); - expect(resultNoMatch.valid).toBe(true); - expect(resultNoMatch.reason).toBe("requirementsMet"); - }); - }); - - describe("emailDomain — missing or malformed data", () => { - const condition = { - key: "emailDomain" as const, - operator: "is" as const, - value: ["@acme.com"], - }; - - it("returns invalid when context has no email", () => { - const result = evaluate([condition], { email: null }); - expect(result.valid).toBe(false); - expect(result.reason).toBe("requirementsNotMet"); - }); - - it("returns invalid when email has no @ sign", () => { - const result = evaluate([condition], { email: "notanemail" }); - expect(result.valid).toBe(false); - expect(result.reason).toBe("requirementsNotMet"); - }); - }); - - describe("case insensitivity", () => { - it("matches uppercase email domain against a lowercase pattern", () => { - const condition = { - key: "emailDomain" as const, - operator: "is" as const, - value: ["@acme.com"], - }; - const result = evaluate([condition], { email: "JANE@ACME.COM" }); - expect(result.valid).toBe(true); - expect(result.reason).toBe("requirementsMet"); - }); - }); - describe("multiple requirements (all must be met)", () => { const countryCondition = { key: "country" as const, operator: "is" as const, value: ["US"], }; - const emailCondition = { - key: "emailDomain" as const, + const identityCondition = { + key: "identityVerificationStatus" as const, operator: "is" as const, - value: ["@acme.com"], + value: "approved" as const, }; - const requirements = [countryCondition, emailCondition]; + const requirements = [countryCondition, identityCondition]; it("returns valid when all conditions are met", () => { const result = evaluate(requirements, { country: "US", - email: "jane@acme.com", + identityVerificationStatus: "approved", }); expect(result.valid).toBe(true); expect(result.reason).toBe("requirementsMet"); @@ -195,7 +88,7 @@ describe("evaluateApplicationRequirements", () => { it("returns invalid when one condition is unmet", () => { const result = evaluate(requirements, { country: "GB", - email: "jane@acme.com", + identityVerificationStatus: "approved", }); expect(result.valid).toBe(false); expect(result.reason).toBe("requirementsNotMet"); @@ -206,7 +99,6 @@ describe("evaluateApplicationRequirements", () => { it("returns valid when requirements array is empty", () => { const result = evaluate([], { country: "US", - email: "jane@acme.com", }); expect(result.valid).toBe(true); expect(result.reason).toBe("noRequirements"); @@ -215,7 +107,6 @@ describe("evaluateApplicationRequirements", () => { it("returns valid when applicationRequirements is null", () => { const result = evaluate(null, { country: "US", - email: "jane@acme.com", }); expect(result.valid).toBe(true); expect(result.reason).toBe("noRequirements"); @@ -224,13 +115,100 @@ describe("evaluateApplicationRequirements", () => { it("returns valid when applicationRequirements is undefined", () => { const result = evaluate(undefined, { country: "US", - email: "jane@acme.com", }); expect(result.valid).toBe(true); expect(result.reason).toBe("noRequirements"); }); }); + describe("identityVerificationStatus", () => { + const condition = { + key: "identityVerificationStatus" as const, + operator: "is" as const, + value: "approved" as const, + }; + + it("returns valid when identityVerificationStatus is approved", () => { + const result = evaluate([condition], { + identityVerificationStatus: "approved", + }); + expect(result.valid).toBe(true); + expect(result.reason).toBe("requirementsMet"); + }); + + it("returns invalid when identityVerificationStatus is not approved", () => { + for (const status of [ + "started", + "submitted", + "declined", + "expired", + "abandoned", + "review", + "resubmissionRequested", + ] as const) { + const result = evaluate([condition], { + identityVerificationStatus: status, + }); + expect(result.valid).toBe(false); + expect(result.reason).toBe("requirementsNotMet"); + } + }); + + it("returns invalid when identityVerificationStatus is null or undefined", () => { + const resultNull = evaluate([condition], { + identityVerificationStatus: null, + }); + expect(resultNull.valid).toBe(false); + expect(resultNull.reason).toBe("requirementsNotMet"); + + const resultUndefined = evaluate([condition], {}); + expect(resultUndefined.valid).toBe(false); + expect(resultUndefined.reason).toBe("requirementsNotMet"); + }); + }); + + describe("combined country + identityVerificationStatus", () => { + const conditions = [ + { + key: "country" as const, + operator: "is" as const, + value: ["US"], + }, + { + key: "identityVerificationStatus" as const, + operator: "is" as const, + value: "approved" as const, + }, + ]; + + it("returns valid when both conditions are met", () => { + const result = evaluate(conditions, { + country: "US", + identityVerificationStatus: "approved", + }); + expect(result.valid).toBe(true); + expect(result.reason).toBe("requirementsMet"); + }); + + it("returns invalid when only country is met", () => { + const result = evaluate(conditions, { + country: "US", + identityVerificationStatus: "submitted", + }); + expect(result.valid).toBe(false); + expect(result.reason).toBe("requirementsNotMet"); + }); + + it("returns invalid when only identity verification is met", () => { + const result = evaluate(conditions, { + country: "GB", + identityVerificationStatus: "approved", + }); + expect(result.valid).toBe(false); + expect(result.reason).toBe("requirementsNotMet"); + }); + }); + describe("invalid requirements", () => { it("returns invalid with reason invalidRequirements when schema parsing fails", () => { const result = evaluate([{ key: "country", operator: "is", value: [] }], { diff --git a/apps/web/tests/misc/eligibility-condition-schema.test.ts b/apps/web/tests/misc/eligibility-condition-schema.test.ts index bcc4d1a0961..715f056c9a6 100644 --- a/apps/web/tests/misc/eligibility-condition-schema.test.ts +++ b/apps/web/tests/misc/eligibility-condition-schema.test.ts @@ -1,76 +1,49 @@ import { eligibilityConditionSchema } from "@/lib/zod/schemas/programs"; import { describe, expect, it } from "vitest"; -describe("eligibilityConditionSchema — emailDomain normalization", () => { - it("prepends @ when missing", () => { - const result = eligibilityConditionSchema.parse({ - key: "emailDomain", - operator: "is", - value: ["domain.com"], - }); - expect(result.value).toEqual(["@domain.com"]); - }); - - it("lowercases the domain", () => { +describe("eligibilityConditionSchema — country (no normalization)", () => { + it("does not alter country codes", () => { const result = eligibilityConditionSchema.parse({ - key: "emailDomain", + key: "country", operator: "is", - value: ["@ACME.COM"], - }); - expect(result.value).toEqual(["@acme.com"]); - }); - - it("normalizes each entry in the array independently", () => { - const result = eligibilityConditionSchema.parse({ - key: "emailDomain", - operator: "is_not", - value: ["ACME.COM", " @Sub.Acme.Com ", "@already.com"], + value: ["US", "CA"], }); - expect(result.value).toEqual([ - "@acme.com", - "@sub.acme.com", - "@already.com", - ]); + expect(result.value).toEqual(["US", "CA"]); }); +}); - it("preserves and lowercases wildcard patterns", () => { +describe("eligibilityConditionSchema — identity verification", () => { + it("accepts the identity verification requirement payload", () => { const result = eligibilityConditionSchema.parse({ - key: "emailDomain", + key: "identityVerificationStatus", operator: "is", - value: ["@*.EDU", "*.Acme.Com"], + value: "approved", }); - expect(result.value).toEqual(["@*.edu", "@*.acme.com"]); - }); -}); - -describe("eligibilityConditionSchema — country (no normalization)", () => { - it("does not alter country codes", () => { - const result = eligibilityConditionSchema.parse({ - key: "country", + expect(result).toEqual({ + key: "identityVerificationStatus", operator: "is", - value: ["US", "CA"], + value: "approved", }); - expect(result.value).toEqual(["US", "CA"]); }); }); describe("eligibilityConditionSchema — validation", () => { - it("rejects an empty value array", () => { + it("rejects an empty country value array", () => { expect(() => eligibilityConditionSchema.parse({ - key: "emailDomain", + key: "country", operator: "is", value: [], }), ).toThrow(); }); - it("rejects a whitespace-only domain entry (normalizes to '@')", () => { + it("rejects invalid identity verification value", () => { expect(() => eligibilityConditionSchema.parse({ - key: "emailDomain", + key: "identityVerificationStatus", operator: "is", - value: [" "], + value: "submitted", }), ).toThrow(); }); diff --git a/apps/web/ui/modals/application-settings-modal.tsx b/apps/web/ui/modals/application-settings-modal.tsx index c26d196d7d0..5a8a88d2a4f 100644 --- a/apps/web/ui/modals/application-settings-modal.tsx +++ b/apps/web/ui/modals/application-settings-modal.tsx @@ -5,12 +5,12 @@ import useProgram from "@/lib/swr/use-program"; import useWorkspace from "@/lib/swr/use-workspace"; import { ApplicationRequirementsDB } from "@/lib/types"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; +import { updateApplicationSettingsSchema } from "@/lib/zod/schemas/programs"; import { EligibilityCondition, EligibilityRequirements, generateId, } from "@/ui/partners/eligibility-requirements"; -import { Category } from "@dub/prisma/client"; import { Button, Modal, ToggleGroup, useEnterSubmit } from "@dub/ui"; import { cn } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; @@ -24,11 +24,13 @@ import { } from "react"; import { Controller, useForm } from "react-hook-form"; import { toast } from "sonner"; +import * as z from "zod/v4"; import { ProgramCategorySelect } from "../partners/program-category-select"; -type FormData = { - description: string; - categories: Category[]; +type FormData = Omit< + z.input, + "workspaceId" | "eligibilityConditions" +> & { eligibilityConditions: EligibilityCondition[]; }; @@ -52,16 +54,26 @@ function ApplicationSettingsModal({ register, formState: { errors, isSubmitting, isDirty }, } = useForm({ - defaultValues: { - description: program?.description ?? "", - categories: program?.categories ?? [], - eligibilityConditions: ( + defaultValues: (() => { + const applicationRequirements = (program?.applicationRequirements as ApplicationRequirementsDB | null) ?? - [] - ) - .filter((c) => c.key === "country") - .map((c) => ({ ...c, key: "country" as const, id: generateId() })), - }, + []; + const selectedCondition = + applicationRequirements.find( + (condition) => condition.key === "identityVerificationStatus", + ) ?? + applicationRequirements.find( + (condition) => condition.key === "country", + ); + + return { + description: program?.description ?? "", + categories: program?.categories ?? [], + eligibilityConditions: selectedCondition + ? [{ ...selectedCondition, id: generateId() }] + : [], + }; + })(), }); const { handleKeyDown } = useEnterSubmit(); @@ -80,16 +92,37 @@ function ApplicationSettingsModal({ const onSubmit = handleSubmit(async (data) => { if (!workspaceId) return; + const eligibilityConditions = data.eligibilityConditions + ?.map((condition) => { + const { key, operator, value } = condition; + if (!key || !operator || value == null) return null; + + if (key === "identityVerificationStatus") { + return { + key: "identityVerificationStatus" as const, + operator: "is" as const, + value: "approved" as const, + }; + } + + if (!Array.isArray(value) || value.length === 0) return null; + + if (key === "country") { + return { + key, + operator, + value, + }; + } + + return null; + }) + .filter((condition) => condition !== null); + const result = await executeAsync({ - workspaceId: workspaceId!, ...data, - eligibilityConditions: data.eligibilityConditions - .filter((c) => c.key && c.operator && c.value && c.value.length > 0) - .map(({ id: _id, key, operator, value }) => ({ - key: key!, - operator: operator!, - value: value!, - })), + workspaceId: workspaceId!, + eligibilityConditions, }); if (result?.serverError || result?.validationErrors) { @@ -165,7 +198,7 @@ function ApplicationSettingsModal({ name="eligibilityConditions" render={({ field }) => ( )} @@ -239,7 +272,7 @@ function ApplicationSettingsModal({ name="categories" render={({ field }) => ( void; onRemove: () => void; }) { - const keyConfig = CONDITION_CONFIGS["country"]; + const selectedKey = condition.key; + const isIdentityCondition = selectedKey === "identityVerificationStatus"; + const keyConfig = selectedKey ? CONDITION_CONFIGS[selectedKey] : null; const handleOperatorChange = (operator: EligibilityOperator) => { onChange({ ...condition, operator, value: null }); }; + const handleKeyChange = (key: ConditionKey) => { + if (key === "identityVerificationStatus") { + onChange({ + ...condition, + key: "identityVerificationStatus", + operator: "is", + value: ["approved"], + }); + return; + } + + onChange({ + ...condition, + key, + operator: null, + value: null, + }); + }; + const handleValueChange = (value: string[]) => { onChange({ ...condition, value }); }; @@ -232,34 +253,62 @@ function ConditionRow({ return (
- + {isIdentityCondition ? ( + + ) : ( + + )}
- If partner {keyConfig.label} + If partner handleOperatorChange(op as EligibilityOperator)} - items={keyConfig.operators.map((op) => ({ - text: OPERATOR_LABELS[op], - value: op, - }))} + selectedValue={selectedKey ?? undefined} + onSelect={(key) => handleKeyChange(key as ConditionKey)} + items={[ + { text: CONDITION_CONFIGS.country.label, value: "country" }, + { + text: CONDITION_CONFIGS.identityVerificationStatus.label, + value: "identityVerificationStatus", + }, + ]} /> - {condition.operator && ( - + {isIdentityCondition ? ( + <>is verified + ) : ( + <> + + + handleOperatorChange(op as EligibilityOperator) + } + items={CONDITION_CONFIGS.country.operators.map((op) => ({ + text: OPERATOR_LABELS[op], + value: op, + }))} + /> + + {condition.operator && ( + + )} + )} @@ -286,9 +335,7 @@ export function EligibilityRequirements({ const handleAdd = () => { if (hasCondition) return; - onChange([ - { id: generateId(), key: "country", operator: null, value: null }, - ]); + onChange([{ id: generateId(), key: null, operator: null, value: null }]); }; const handleChange = (updated: EligibilityCondition) => { diff --git a/apps/web/ui/partners/partner-info-cards.tsx b/apps/web/ui/partners/partner-info-cards.tsx index f16c39eca38..7631b549c8f 100644 --- a/apps/web/ui/partners/partner-info-cards.tsx +++ b/apps/web/ui/partners/partner-info-cards.tsx @@ -21,6 +21,7 @@ import { TimestampTooltip, Trophy, } from "@dub/ui"; +import { VerifiedBadge } from "@dub/ui/icons"; import { COUNTRIES, capitalize, @@ -198,6 +199,16 @@ export function PartnerInfoCards({ ]; })() : []), + ...(partner?.identityVerificationStatus === "approved" && + partner?.identityVerifiedAt + ? [ + { + id: "identityVerifiedAt", + icon: , + text: `Identity verified ${formatDate(partner.identityVerifiedAt, { month: "short" })}`, + }, + ] + : []), ]); } @@ -237,6 +248,16 @@ export function PartnerInfoCards({ icon: , text: partner ? `Joined ${formatDate(partner.createdAt!)}` : undefined, }, + ...(partner?.identityVerificationStatus === "approved" && + partner?.identityVerifiedAt + ? [ + { + id: "identityVerifiedAt", + icon: , + text: `Identity verified ${formatDate(partner.identityVerifiedAt, { month: "short" })}`, + }, + ] + : []), ]); } diff --git a/apps/web/ui/partners/program-eligibility-card.tsx b/apps/web/ui/partners/program-eligibility-card.tsx index f05c7fc715e..94027f8e840 100644 --- a/apps/web/ui/partners/program-eligibility-card.tsx +++ b/apps/web/ui/partners/program-eligibility-card.tsx @@ -26,6 +26,10 @@ function formatConditionText(condition: EligibilityConditionDB): string { } } + if (condition.key === "identityVerificationStatus") { + return "Your identity is verified"; + } + // emailDomain — commented out, preserved for future use // if (condition.key === "emailDomain") { // const joined = oxfordJoin(condition.value); @@ -56,7 +60,7 @@ export function ProgramEligibilityCard({ const context = { country: partner?.country, - email: partner?.email, + identityVerificationStatus: partner?.identityVerificationStatus, }; const unmet = requirements.filter( diff --git a/packages/email/src/templates/partner-identity-verification-failed.tsx b/packages/email/src/templates/partner-identity-verification-failed.tsx new file mode 100644 index 00000000000..57e6f9c9de9 --- /dev/null +++ b/packages/email/src/templates/partner-identity-verification-failed.tsx @@ -0,0 +1,70 @@ +import { DUB_WORDMARK } from "@dub/utils"; +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../components/footer"; + +export default function PartnerIdentityVerificationFailed({ + partner = { + name: "John", + email: "panic@thedis.co", + identityVerificationDeclineReason: + "Document Obscured: ID document is partially obscured (e.g. by fingers)", + }, +}: { + partner: { + name: string; + email: string; + identityVerificationDeclineReason: string; + }; +}) { + return ( + + + Your identity verification failed + + + +
+ dub +
+ + + Identity verification failed + + + + Hi {partner.name}, your identity verification couldn't be + completed because {partner.identityVerificationDeclineReason}. + + + + Please log back in to your dashboard and resubmit your details. + + +
+ + Go to your dashboard + +
+ +