Enhance user verification and data validation features

- Updated `next.config.ts` to include remote image patterns for user verification.
- Introduced `VerifyUserDetails` function in `lib/person.ts` to validate user data against national records.
- Added `usePerson` hook for fetching national data based on ID card.
- Enhanced `signup` and `signin` functions in `auth-actions.ts` to handle user verification status and send notifications for pending verifications.
- Refactored `VerifyUser` function in `user-actions.ts` to incorporate national data validation.
- Improved UI components in the user verification page to display both database and national information.
- Updated `InputReadOnly` component to support customizable label classes for better styling.

These changes improve the user verification process, ensuring data integrity and enhancing the overall user experience.
This commit is contained in:
i701 2025-01-10 15:59:44 +05:00
parent 1a195d2307
commit ff0eae6ec4
12 changed files with 253 additions and 123 deletions

View File

@ -2,11 +2,13 @@
import { authClient } from "@/lib/auth-client";
import prisma from "@/lib/db";
import VerifyUserDetails from "@/lib/person";
import { signUpFormSchema } from "@/lib/schemas";
import { headers } from "next/headers";
// import type { User } from "@prisma/client";
import { redirect } from "next/navigation";
import { z } from "zod";
import { SendUserRejectionDetailSMS } from "./user-actions";
const formSchema = z.object({
phoneNumber: z
.string()
@ -41,6 +43,14 @@ export async function signin(previousState: ActionState, formData: FormData) {
if (!userExists) {
return redirect(`/signup?phone_number=${phoneNumber}`);
}
if (!userExists?.verified)
return {
message:
"Your account is on pending verification. Please wait for a response from admin or contact shihaam.",
status: "error",
};
await authClient.phoneNumber.sendOtp({
phoneNumber: NUMBER_WITH_COUNTRY_CODE,
});
@ -79,6 +89,7 @@ export async function signup(_actionState: ActionState, formData: FormData) {
NUMBER_WITH_COUNTRY_CODE = `+960${parsedData.data.phone_number.split("-").join("")}`;
}
console.log({ NUMBER_WITH_COUNTRY_CODE });
const idCardExists = await prisma.user.findFirst({
where: {
id_card: parsedData.data.id_card,
@ -120,14 +131,39 @@ export async function signup(_actionState: ActionState, formData: FormData) {
phoneNumber: NUMBER_WITH_COUNTRY_CODE,
},
});
await authClient.phoneNumber.sendOtp({
phoneNumber: newUser.phoneNumber,
});
const isValidPerson = await VerifyUserDetails({ user: newUser });
if (!isValidPerson) {
await SendUserRejectionDetailSMS({
details: `
A new user has requested for verification.
USER DETAILS:
Name: ${parsedData.data.name}
Address: ${parsedData.data.address}
ID Card: ${parsedData.data.id_card}
DOB: ${parsedData.data.dob}
ACC No: ${parsedData.data.accNo}
Verify the user with the folloiwing link: ${process.env.BETTER_AUTH_URL}/users/${newUser.id}/verify
`,
phoneNumber: process.env.ADMIN_PHONENUMBER ?? "",
});
return {
message:
"Your account has been requested for verification. Please wait for a response from admin.",
payload: formData,
db_error: "invalidPersonValidation",
};
}
if (isValidPerson) {
await authClient.phoneNumber.sendOtp({
phoneNumber: newUser.phoneNumber,
});
}
redirect(
`/verify-otp?phone_number=${encodeURIComponent(newUser.phoneNumber)}`,
);
return { message: "Post created" };
return { message: "User created successfully" };
}
export const sendOtp = async (phoneNumber: string, code: string) => {

View File

@ -1,25 +1,4 @@
"use server";
// const raw = {
// group_settings_id: "",
// address1: "",
// city: "F",
// state: "Dharanboodhoo",
// postal_code: "12040",
// country_id: "462",
// address2: "Skyvilla",
// contacts: [
// {
// first_name: "Abdulla",
// last_name: "Aidhaan",
// email: "",
// phone: "778-0588",
// send_email: false,
// custom_value1: "1971-02-24",
// custom_value2: "A265117",
// custom_value3: "",
// },
// ],
// };
type CreateClientProps = {
group_settings_id: string;

View File

@ -1,6 +1,8 @@
"use server";
import usePerson from "@/hooks/use-person";
import prisma from "@/lib/db";
import VerifyUserDetails from "@/lib/person";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { CreateClient } from "./ninja/client";
@ -18,33 +20,42 @@ export async function VerifyUser(userId: string) {
if (!user) {
throw new Error("User not found");
}
await prisma.user.update({
where: {
id: userId,
},
data: {
verified: true,
},
});
const ninjaClient = await CreateClient({
group_settings_id: "",
address1: "",
city: user.atoll?.name || "",
state: user.island?.name || "",
postal_code: "",
country_id: "462",
address2: user.address || "",
contacts: {
first_name: user.name?.split(" ")[0] || "",
last_name: user.name?.split(" ")[1] || "",
email: user.email || "",
phone: user.phoneNumber || "",
send_email: false,
custom_value1: user.dob?.toISOString().split("T")[0] || "",
custom_value2: user.id_card || "",
custom_value3: "",
},
});
const isValidPerson = await VerifyUserDetails({ user });
if (!isValidPerson)
throw new Error("The user details does not match national data.");
if (isValidPerson) {
await prisma.user.update({
where: {
id: userId,
},
data: {
verified: true,
},
});
const ninjaClient = await CreateClient({
group_settings_id: "",
address1: "",
city: user.atoll?.name || "",
state: user.island?.name || "",
postal_code: "",
country_id: "462",
address2: user.address || "",
contacts: {
first_name: user.name?.split(" ")[0] || "",
last_name: user.name?.split(" ")[1] || "",
email: user.email || "",
phone: user.phoneNumber || "",
send_email: false,
custom_value1: user.dob?.toISOString().split("T")[0] || "",
custom_value2: user.id_card || "",
custom_value3: "",
},
});
}
revalidatePath("/users");
}

View File

@ -2,8 +2,11 @@ import InputReadOnly from '@/components/input-read-only';
import { Badge } from '@/components/ui/badge';
import UserRejectDialog from '@/components/user/user-reject-dialog';
import { UserVerifyDialog } from '@/components/user/user-verify-dialog';
import usePerson from '@/hooks/use-person';
import prisma from '@/lib/db';
import type { TNationalPerson } from '@/lib/types';
import Image from 'next/image';
import React from 'react'
export default async function VerifyUserPage({
@ -18,7 +21,16 @@ export default async function VerifyUserPage({
where: {
id: userId,
},
include: {
island: {
include: {
atoll: true
}
}
}
})
const nationalData = await usePerson({ idCard: dbUser?.id_card ?? "" })
return (
<div>
<div className='flex items-center justify-between text-gray-500 text-2xl font-bold title-bg py-4 px-2 mb-4'>
@ -35,30 +47,45 @@ export default async function VerifyUserPage({
<div className='grid grid-cols-1 md:grid-cols-2 gap-4 items-start justify-start'>
<div id="database-information">
<h4 className='p-2 rounded font-semibold'>Database Information</h4>
<div className='shadow p-2 rounded-md space-y-1 my-2 grid grid-cols-1 md:grid-cols-2 gap-2'>
<InputReadOnly label="Name" value={dbUser?.name ?? ""} />
<InputReadOnly label="ID Card" value={dbUser?.id_card ?? ""} />
<InputReadOnly label="Address" value={dbUser?.address ?? ""} />
<InputReadOnly label="DOB" value={new Date(dbUser?.dob ?? "").toLocaleDateString("en-US", {
<div className='shadow p-2 rounded-lg title-bg space-y-1 my-2 grid grid-cols-1 md:grid-cols-2 gap-2'>
<InputReadOnly labelClassName='text-sarLinkOrange' label="ID Card" value={dbUser?.id_card ?? ""} />
<InputReadOnly labelClassName='text-sarLinkOrange' label="Name" value={dbUser?.name ?? ""} />
<InputReadOnly labelClassName='text-sarLinkOrange' label="House Name" value={dbUser?.address ?? ""} />
<InputReadOnly labelClassName='text-sarLinkOrange' label="Island" value={dbUser?.island?.name ?? ""} />
<InputReadOnly labelClassName='text-sarLinkOrange' label="Atoll" value={dbUser?.island?.atoll.name ?? ""} />
<InputReadOnly labelClassName='text-sarLinkOrange' label="DOB" value={new Date(dbUser?.dob ?? "").toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})} />
<InputReadOnly label="Phone Number" value={dbUser?.phoneNumber ?? ""} />
<InputReadOnly labelClassName='text-sarLinkOrange' label="Phone Number" value={dbUser?.phoneNumber ?? ""} />
</div>
</div>
<div id="national-information">
<h4 className='p-2 rounded font-semibold'>National Information</h4>
<div className='shadow p-2 rounded-md space-y-1 my-2 grid grid-cols-1 md:grid-cols-2 gap-2'>
<InputReadOnly label="Name" value={dbUser?.name ?? ""} />
<InputReadOnly label="ID Card" value={dbUser?.id_card ?? ""} />
<InputReadOnly label="Address" value={dbUser?.address ?? ""} />
<InputReadOnly label="DOB" value={new Date(dbUser?.dob ?? "").toLocaleDateString("en-US", {
<div className='shadow p-2 rounded-md title-bg space-y-1 my-2 grid grid-cols-1 md:grid-cols-2 gap-2'>
<InputReadOnly labelClassName='text-green-500' label="ID Card" value={nationalData?.nic ?? ""} />
<InputReadOnly labelClassName='text-green-500' label="Name" value={nationalData?.name_en ?? ""} />
<InputReadOnly labelClassName='text-green-500' label="House Name" value={nationalData?.house_name_en ?? ""} />
<InputReadOnly labelClassName='text-green-500' label="Island" value={nationalData?.island_name_en ?? ""} />
<InputReadOnly labelClassName='text-green-500' label="Atoll" value={nationalData?.atoll_en ?? ""} />
<InputReadOnly labelClassName='text-green-500' label="DOB" value={new Date(nationalData?.dob ?? "").toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})} />
<InputReadOnly label="Phone Number" value={dbUser?.phoneNumber ?? ""} />
<InputReadOnly labelClassName='text-green-500' label="Phone Number" value={nationalData?.primary_contact ?? ""} />
<div className='flex flex-col col-span-2 items-center justify-center'>
<Image
src={nationalData.image_url}
height={100}
width={100}
className='object-fit aspect-square rounded-full'
alt='id photo'
/>
</div>
</div>
</div>
</div>

View File

@ -9,7 +9,6 @@ import type { Island, Prisma } from "@prisma/client";
import { Loader2 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import * as React from "react";
import { useActionState } from "react";
import {
Select,
@ -31,7 +30,7 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
const [atoll, setAtoll] = React.useState<AtollWithIslands>();
const [islands, setIslands] = React.useState<Island[]>();
const [actionState, action, isPending] = useActionState(signup, {
const [actionState, action, isPending] = React.useActionState(signup, {
message: "",
});
@ -45,6 +44,7 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
const phoneNumberFromUrl = params.get("phone_number");
const NUMBER_WITHOUT_DASH = phoneNumberFromUrl?.split("-").join("");
return (
<form
action={action}
@ -59,17 +59,17 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
<Input
className={cn(
"text-base",
actionState.errors?.fieldErrors.name && "border-2 border-red-500",
actionState?.errors?.fieldErrors.name && "border-2 border-red-500",
)}
name="name"
type="text"
disabled={isPending}
defaultValue={(actionState.payload?.get("name") || "") as string}
defaultValue={(actionState?.payload?.get("name") || "") as string}
placeholder="Full Name"
/>
{actionState.errors?.fieldErrors.name && (
{actionState?.errors?.fieldErrors.name && (
<span className="text-sm inline-block text-red-500">
{actionState.errors?.fieldErrors.name}
{actionState?.errors?.fieldErrors.name}
</span>
)}
</div>
@ -82,22 +82,22 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
type="text"
maxLength={7}
disabled={isPending}
defaultValue={(actionState.payload?.get("id_card") || "") as string}
defaultValue={(actionState?.payload?.get("id_card") || "") as string}
className={cn(
"text-base",
actionState.errors?.fieldErrors?.id_card &&
actionState?.errors?.fieldErrors?.id_card &&
"border-2 border-red-500",
)}
placeholder="ID Card"
/>
{actionState?.errors?.fieldErrors?.id_card?.[0] && (
<span className="text-sm inline-block text-red-500">
{actionState.errors.fieldErrors.id_card[0]}
{actionState?.errors.fieldErrors.id_card[0]}
</span>
)}
{actionState.db_error === "id_card" && (
{actionState?.db_error === "id_card" && (
<span className="text-sm inline-block text-red-500">
{actionState.message}
{actionState?.message}
</span>
)}
</div>
@ -128,9 +128,9 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
))}
</SelectGroup>
</SelectContent>
{actionState.errors?.fieldErrors?.atoll_id && (
{actionState?.errors?.fieldErrors?.atoll_id && (
<span className="text-sm inline-block text-red-500">
{actionState.errors?.fieldErrors?.atoll_id}
{actionState?.errors?.fieldErrors?.atoll_id}
</span>
)}
</Select>
@ -153,9 +153,9 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
))}
</SelectGroup>
</SelectContent>
{actionState.errors?.fieldErrors?.island_id && (
{actionState?.errors?.fieldErrors?.island_id && (
<span className="text-sm inline-block text-red-500">
{actionState.errors?.fieldErrors?.island_id}
{actionState?.errors?.fieldErrors?.island_id}
</span>
)}
</Select>
@ -169,20 +169,20 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
<Input
className={cn(
"text-base",
actionState.errors?.fieldErrors?.address &&
actionState?.errors?.fieldErrors?.address &&
"border-2 border-red-500",
)}
disabled={isPending}
name="address"
defaultValue={
(actionState.payload?.get("address") || "") as string
(actionState?.payload?.get("address") || "") as string
}
type="text"
placeholder="Address"
/>
{actionState.errors?.fieldErrors?.address && (
{actionState?.errors?.fieldErrors?.address && (
<span className="text-sm inline-block text-red-500">
{actionState.errors?.fieldErrors?.address}
{actionState?.errors?.fieldErrors?.address}
</span>
)}
</div>
@ -194,17 +194,17 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
<Input
className={cn(
"text-base",
actionState.errors?.fieldErrors?.dob && "border-2 border-red-500",
actionState?.errors?.fieldErrors?.dob && "border-2 border-red-500",
)}
name="dob"
disabled={isPending}
defaultValue={(actionState.payload?.get("dob") || "") as string}
defaultValue={(actionState?.payload?.get("dob") || "") as string}
type="date"
placeholder="Date of birth"
/>
{actionState.errors?.fieldErrors?.dob && (
{actionState?.errors?.fieldErrors?.dob && (
<span className="text-sm inline-block text-red-500">
{actionState.errors?.fieldErrors?.dob}
{actionState?.errors?.fieldErrors?.dob}
</span>
)}
</div>
@ -216,17 +216,17 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
<Input
className={cn(
"text-base",
actionState.errors?.fieldErrors.accNo && "border-2 border-red-500",
actionState?.errors?.fieldErrors.accNo && "border-2 border-red-500",
)}
name="accNo"
type="number"
disabled={isPending}
defaultValue={(actionState.payload?.get("accNo") || "") as string}
defaultValue={(actionState?.payload?.get("accNo") || "") as string}
placeholder="Account no"
/>
{actionState.errors?.fieldErrors.accNo && (
{actionState?.errors?.fieldErrors.accNo && (
<span className="text-sm inline-block text-red-500">
{actionState.errors?.fieldErrors.accNo}
{actionState?.errors?.fieldErrors.accNo}
</span>
)}
</div>
@ -241,7 +241,7 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
disabled={isPending}
className={cn(
!phoneNumberFromUrl &&
actionState.errors?.fieldErrors?.phone_number &&
actionState?.errors?.fieldErrors?.phone_number &&
"border-2 border-red-500 rounded-md",
)}
defaultValue={NUMBER_WITHOUT_DASH ?? ""}
@ -251,19 +251,19 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
</div>
{actionState?.errors?.fieldErrors?.phone_number?.[0] && (
<span className="text-sm inline-block text-red-500">
{actionState.errors.fieldErrors.phone_number[0]}
{actionState?.errors.fieldErrors.phone_number[0]}
</span>
)}
{actionState.db_error === "phone_number" && (
{actionState?.db_error === "phone_number" && (
<span className="text-sm inline-block text-red-500">
{actionState.message}
{actionState?.message}
</span>
)}
<div className="flex flex-col gap-2 items-start justify-start py-2">
<div className="flex gap-2 items-center">
<input
type="checkbox"
defaultChecked={(actionState.payload?.get("terms") || "") as string === 'on'}
defaultChecked={(actionState?.payload?.get("terms") || "") as string === 'on'}
name="terms" id="terms" />
<label
htmlFor="terms"
@ -277,16 +277,16 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
</Link>
</label>
</div>
{actionState.errors?.fieldErrors?.terms && (
{actionState?.errors?.fieldErrors?.terms && (
<span className="text-sm inline-block text-red-500">
{actionState.errors?.fieldErrors?.terms}
{actionState?.errors?.fieldErrors?.terms}
</span>
)}
<div className="flex gap-2 items-center">
<input
type="checkbox"
defaultChecked={(actionState.payload?.get("policy") || "") as string === 'on'}
defaultChecked={(actionState?.payload?.get("policy") || "") as string === 'on'}
name="policy" id="terms" />
<label
htmlFor="terms"
@ -300,9 +300,9 @@ export default function SignUpForm({ atolls }: { atolls: AtollWithIslands[] }) {
</Link>
</label>
</div>
{actionState.errors?.fieldErrors?.policy && (
{actionState?.errors?.fieldErrors?.policy && (
<span className="text-sm inline-block text-red-500">
{actionState.errors?.fieldErrors?.policy}
{actionState?.errors?.fieldErrors?.policy}
</span>
)}

View File

@ -1,9 +1,10 @@
import { cn } from '@/lib/utils'
import React from 'react'
export default function InputReadOnly({ label, value }: { label: string, value?: string }) {
export default function InputReadOnly({ label, value, labelClassName, className }: { label: string, value: string, labelClassName?: string, className?: string }) {
return (
<div className="relative rounded-lg border border-input bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&:has(input:is(:disabled))_*]:pointer-events-none">
<label htmlFor="input-33" className="block px-3 pt-2 text-xs font-medium text-foreground">
<div className={cn("relative rounded-lg border border-input bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-80 [&:has(input:is(:disabled))_*]:pointer-events-none", className)}>
<label htmlFor="input-33" className={cn("block px-3 pt-2 text-xs font-medium", labelClassName)}>
{label}
</label>
<input

View File

@ -61,9 +61,9 @@ export function UserVerifyDialog({ user }: { user: User }) {
setDisabled(false);
return "User Verified!";
},
error: () => {
error: (error: Error) => {
setDisabled(false);
return "Something went wrong";
return error.message || "Something went wrong";
},
});
}}

16
hooks/use-person.ts Normal file
View File

@ -0,0 +1,16 @@
import type { TNationalPerson } from "@/lib/types";
export default async function usePerson({
idCard,
}: { idCard: string }): Promise<TNationalPerson> {
const nationalInformation = await fetch(
`${process.env.PERSON_VERIFY_API_BASE}/api/person/${idCard}`,
{
next: {
revalidate: 60,
},
},
);
const nationalData = (await nationalInformation.json()) as TNationalPerson;
return nationalData;
}

21
lib/person.ts Normal file
View File

@ -0,0 +1,21 @@
"use server";
import usePerson from "@/hooks/use-person";
import type { User } from "@prisma/client";
export default async function VerifyUserDetails({ user }: { user: User }) {
const phoneNumber = user.phoneNumber.slice(4);
const nationalData = await usePerson({ idCard: user.id_card ?? "" });
const dob = new Date(nationalData.dob);
const age = new Date().getFullYear() - dob.getFullYear();
if (
user.id_card === nationalData.nic &&
user.name === nationalData.name_en &&
user.address === nationalData.house_name_en &&
phoneNumber === nationalData.primary_contact &&
age >= 18
) {
return true;
}
return false;
}

View File

@ -48,3 +48,23 @@ export interface OmadaResponse {
data: GroupProfile[];
};
}
export interface TNationalPerson {
nic: string;
name: string;
name_en: string;
dob: string;
gender: "M" | "F"; // Assuming gender can only be Male or Female
house_name: string;
house_name_en: string;
island_name: string;
island_name_en: string;
atoll: string;
atoll_en: string;
constituency: string;
district_en: string | null;
block_no: string | null;
email: string | null;
primary_contact: string;
image_url: string;
}

View File

@ -1,8 +1,19 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
/* config options here */
images: {
remotePatterns: [
{
protocol: "http",
hostname: "verifypersonapi.baraveli.dev",
pathname: "/images/**",
search: "",
port: "",
},
],
},
output: "standalone",
};
export default nextConfig;

View File

@ -7,36 +7,44 @@ const DEFAULT_ISLANDS = ["Dharanboodhoo", "Feeali", "Nilandhoo", "Magoodhoo"];
async function main() {
await prisma.user.upsert({
where: {
phoneNumber: "+9607780588",
phoneNumber: process.env.ADMIN_PHONENUMBER,
},
update: {},
create: {
name: "Admin Admin",
email: "admin@example.com",
email: process.env.ADMIN_EMAIL,
emailVerified: true,
verified: true,
address: "Sky villa",
id_card: "A265117",
id_card: process.env.ADMIN_IDCARD,
dob: new Date("1990-01-01"),
phoneNumber: "+9607780588",
phoneNumber: process.env.ADMIN_PHONENUMBER ?? "",
phoneNumberVerified: true,
role: "ADMIN",
},
});
const FAAFU_ATOLL = await prisma.atoll.create({
data: {
let FAAFU_ATOLL_ID = "";
const atollExists = await prisma.atoll.findFirst({
where: {
name: "F",
},
});
if (!atollExists) {
const NEW_ATOLL = await prisma.atoll.create({
data: {
name: "F",
},
});
FAAFU_ATOLL_ID = NEW_ATOLL.id;
const islands = DEFAULT_ISLANDS.map((name) => ({
name,
atollId: FAAFU_ATOLL.id,
}));
await prisma.island.createMany({
data: islands,
});
const islands = DEFAULT_ISLANDS.map((name) => ({
name,
atollId: FAAFU_ATOLL_ID,
}));
await prisma.island.createMany({
data: islands,
});
}
}
main()