refactor: update authentication flow to use NextAuth, replace better-auth with axios for API calls, and clean up unused code

This commit is contained in:
i701 2025-03-23 15:07:03 +05:00
parent 0fd269df31
commit 020d74c5e2
23 changed files with 1269 additions and 1271 deletions

View File

@ -1,10 +1,7 @@
"use server"; "use server";
import { authClient } from "@/lib/auth-client";
import prisma from "@/lib/db";
import { VerifyUserDetails } from "@/lib/person"; import { VerifyUserDetails } from "@/lib/person";
import { signUpFormSchema } from "@/lib/schemas"; import { signUpFormSchema } from "@/lib/schemas";
import { phoneNumber } from "better-auth/plugins";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
@ -34,7 +31,7 @@ export async function signin(previousState: ActionState, formData: FormData) {
}; };
} }
const FORMATTED_MOBILE_NUMBER: string = `${phoneNumber.split("-").join("")}`; const FORMATTED_MOBILE_NUMBER: string = `${phoneNumber.split("-").join("")}`;
console.log(FORMATTED_MOBILE_NUMBER); console.log({ FORMATTED_MOBILE_NUMBER });
const userExistsResponse = await fetch( const userExistsResponse = await fetch(
`${process.env.SARLINK_API_BASE_URL}/auth/mobile/`, `${process.env.SARLINK_API_BASE_URL}/auth/mobile/`,
{ {
@ -48,7 +45,7 @@ export async function signin(previousState: ActionState, formData: FormData) {
}, },
); );
const userExists = await userExistsResponse.json(); const userExists = await userExistsResponse.json();
console.log(userExists.non_field_errors); console.log("user exists", userExists);
if (userExists?.non_field_errors) { if (userExists?.non_field_errors) {
return redirect(`/signup?phone_number=${phoneNumber}`); return redirect(`/signup?phone_number=${phoneNumber}`);
} }
@ -75,7 +72,6 @@ export async function signup(_actionState: ActionState, formData: FormData) {
const data = Object.fromEntries(formData.entries()); const data = Object.fromEntries(formData.entries());
const parsedData = signUpFormSchema.safeParse(data); const parsedData = signUpFormSchema.safeParse(data);
// get phone number from /signup?phone_number=999-1231 // get phone number from /signup?phone_number=999-1231
const headersList = await headers();
console.log("DATA ON SERVER SIDE", data); console.log("DATA ON SERVER SIDE", data);
@ -87,83 +83,82 @@ export async function signup(_actionState: ActionState, formData: FormData) {
}; };
} }
const idCardExists = await prisma.user.findFirst({ // const idCardExists = await prisma.user.findFirst({
where: { // where: {
id_card: parsedData.data.id_card, // id_card: parsedData.data.id_card,
}, // },
}); // });
if (idCardExists) { // if (idCardExists) {
return { // return {
message: "ID card already exists.", // message: "ID card already exists.",
payload: formData, // payload: formData,
db_error: "id_card", // db_error: "id_card",
}; // };
} // }
const phoneNumberExists = await prisma.user.findFirst({ // const phoneNumberExists = await prisma.user.findFirst({
where: { // where: {
phoneNumber: parsedData.data.phone_number, // phoneNumber: parsedData.data.phone_number,
}, // },
}); // });
if (phoneNumberExists) { // if (phoneNumberExists) {
return { // return {
message: "Phone number already exists.", // message: "Phone number already exists.",
payload: formData, // payload: formData,
db_error: "phone_number", // db_error: "phone_number",
}; // };
} // }
const newUser = await prisma.user.create({ // const newUser = await prisma.user.create({
data: { // data: {
name: parsedData.data.name, // name: parsedData.data.name,
islandId: parsedData.data.island_id, // islandId: parsedData.data.island_id,
atollId: parsedData.data.atoll_id, // atollId: parsedData.data.atoll_id,
address: parsedData.data.address, // address: parsedData.data.address,
id_card: parsedData.data.id_card, // id_card: parsedData.data.id_card,
dob: new Date(parsedData.data.dob), // dob: new Date(parsedData.data.dob),
role: "USER", // role: "USER",
accNo: parsedData.data.accNo, // accNo: parsedData.data.accNo,
phoneNumber: parsedData.data.phone_number, // phoneNumber: parsedData.data.phone_number,
}, // },
}); // });
const isValidPerson = await VerifyUserDetails({ user: newUser }); // const isValidPerson = await VerifyUserDetails({ user: newUser });
if (!isValidPerson) { // if (!isValidPerson) {
await SendUserRejectionDetailSMS({ // await SendUserRejectionDetailSMS({
details: ` // details: `
A new user has requested for verification. \n // A new user has requested for verification. \n
USER DETAILS: // USER DETAILS:
Name: ${parsedData.data.name} // Name: ${parsedData.data.name}
Address: ${parsedData.data.address} // Address: ${parsedData.data.address}
ID Card: ${parsedData.data.id_card} // ID Card: ${parsedData.data.id_card}
DOB: ${parsedData.data.dob.toLocaleDateString("en-US", { // DOB: ${parsedData.data.dob.toLocaleDateString("en-US", {
month: "short", // month: "short",
day: "2-digit", // day: "2-digit",
year: "numeric", // year: "numeric",
})} // })}
ACC No: ${parsedData.data.accNo}\n\nVerify the user with the following link: ${process.env.BETTER_AUTH_URL}/users/${newUser.id}/verify // ACC No: ${parsedData.data.accNo}\n\nVerify the user with the following link: ${process.env.BETTER_AUTH_URL}/users/${newUser.id}/verify
`, // `,
phoneNumber: process.env.ADMIN_PHONENUMBER ?? "", // phoneNumber: process.env.ADMIN_PHONENUMBER ?? "",
}); // });
return { // return {
message: // message:
"Your account has been requested for verification. Please wait for a response from admin.", // "Your account has been requested for verification. Please wait for a response from admin.",
payload: formData, // payload: formData,
db_error: "invalidPersonValidation", // db_error: "invalidPersonValidation",
}; // };
}
if (isValidPerson) { // if (isValidPerson) {
await authClient.phoneNumber.sendOtp({ // await authClient.phoneNumber.sendOtp({
phoneNumber: newUser.phoneNumber, // phoneNumber: newUser.phoneNumber,
}); // });
} // }
redirect( // redirect(
`/verify-otp?phone_number=${encodeURIComponent(newUser.phoneNumber)}`, // `/verify-otp?phone_number=${encodeURIComponent(newUser.phoneNumber)}`,
); // );
return { message: "User created successfully" }; // return { message: "User created successfully" };
} }
export const sendOtp = async (phoneNumber: string, code: string) => { export const sendOtp = async (phoneNumber: string, code: string) => {

View File

@ -1,85 +1,79 @@
"use server"; "use server";
import prisma from "@/lib/db";
import { VerifyUserDetails } from "@/lib/person"; import { VerifyUserDetails } from "@/lib/person";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { CreateClient } from "./ninja/client"; import { CreateClient } from "./ninja/client";
export async function VerifyUser(userId: string) { export async function VerifyUser(userId: string) {
const user = await prisma.user.findUnique({ // const user = await prisma.user.findUnique({
where: { // where: {
id: userId, // id: userId,
}, // },
include: { // include: {
atoll: true, // atoll: true,
island: true, // island: true,
}, // },
}); // });
if (!user) { // if (!user) {
throw new Error("User not found"); // throw new Error("User not found");
} // }
const isValidPerson = await VerifyUserDetails({ user }); // const isValidPerson = await VerifyUserDetails({ user });
// if (!isValidPerson)
if (!isValidPerson) // throw new Error("The user details does not match national data.");
throw new Error("The user details does not match national data."); // if (isValidPerson) {
// await prisma.user.update({
if (isValidPerson) { // where: {
await prisma.user.update({ // id: userId,
where: { // },
id: userId, // data: {
}, // verified: true,
data: { // },
verified: true, // });
}, // const ninjaClient = await CreateClient({
}); // group_settings_id: "",
// address1: "",
const ninjaClient = await CreateClient({ // city: user.atoll?.name || "",
group_settings_id: "", // state: user.island?.name || "",
address1: "", // postal_code: "",
city: user.atoll?.name || "", // country_id: "462",
state: user.island?.name || "", // address2: user.address || "",
postal_code: "", // contacts: {
country_id: "462", // first_name: user.name?.split(" ")[0] || "",
address2: user.address || "", // last_name: user.name?.split(" ")[1] || "",
contacts: { // email: user.email || "",
first_name: user.name?.split(" ")[0] || "", // phone: user.phoneNumber || "",
last_name: user.name?.split(" ")[1] || "", // send_email: false,
email: user.email || "", // custom_value1: user.dob?.toISOString().split("T")[0] || "",
phone: user.phoneNumber || "", // custom_value2: user.id_card || "",
send_email: false, // custom_value3: "",
custom_value1: user.dob?.toISOString().split("T")[0] || "", // },
custom_value2: user.id_card || "", // });
custom_value3: "", // }
}, // revalidatePath("/users");
});
}
revalidatePath("/users");
} }
export async function Rejectuser({ export async function Rejectuser({
userId, userId,
reason, reason,
}: { userId: string; reason: string }) { }: { userId: string; reason: string }) {
const user = await prisma.user.findUnique({ // const user = await prisma.user.findUnique({
where: { // where: {
id: userId, // id: userId,
}, // },
}); // });
if (!user) { // if (!user) {
throw new Error("User not found"); // throw new Error("User not found");
} // }
await SendUserRejectionDetailSMS({ // await SendUserRejectionDetailSMS({
details: reason, // details: reason,
phoneNumber: user.phoneNumber, // phoneNumber: user.phoneNumber,
}); // });
await prisma.user.delete({ // await prisma.user.delete({
where: { // where: {
id: userId, // id: userId,
}, // },
}); // });
revalidatePath("/users"); revalidatePath("/users");
redirect("/users"); redirect("/users");
} }
@ -117,13 +111,13 @@ export async function AddDevice({
mac_address, mac_address,
user_id, user_id,
}: { name: string; mac_address: string; user_id: string }) { }: { name: string; mac_address: string; user_id: string }) {
const newDevice = await prisma.device.create({ // const newDevice = await prisma.device.create({
data: { // data: {
name: name, // name: name,
mac: mac_address, // mac: mac_address,
userId: user_id, // userId: user_id,
}, // },
}); // });
revalidatePath("/devices"); revalidatePath("/devices");
return newDevice; // return newDevice;
} }

View File

@ -1,12 +1,11 @@
import LoginForm from "@/components/auth/login-form"; import LoginForm from "@/components/auth/login-form";
import { auth } from "@/lib/auth"; import { auth } from "@/app/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Image from "next/image"; import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import React from "react"; import React from "react";
export default async function LoginPage() { export default async function LoginPage() {
return ( return (
<div className="dark:bg-black w-full h-screen flex items-center justify-center font-sans"> <div className="dark:bg-black w-full h-screen flex items-center justify-center font-sans">
<div className="flex flex-col items-center justify-center w-full h-full "> <div className="flex flex-col items-center justify-center w-full h-full ">

View File

@ -1,51 +1,53 @@
import DevicesToPay from "@/components/devices-to-pay"; import DevicesToPay from "@/components/devices-to-pay";
import { auth } from "@/lib/auth"; import { auth } from "@/app/auth";
import prisma from "@/lib/db"; import prisma from "@/lib/db";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { headers } from "next/headers"; import { headers } from "next/headers";
import React from "react"; import React from "react";
export default async function PaymentPage({ export default async function PaymentPage({
params, params,
}: { }: {
params: Promise<{ paymentId: string }>; params: Promise<{ paymentId: string }>;
}) { }) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers(),
}) });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: session?.session.userId id: session?.session.userId,
} },
}) });
const paymentId = (await params).paymentId; const paymentId = (await params).paymentId;
const payment = await prisma.payment.findUnique({ const payment = await prisma.payment.findUnique({
where: { where: {
id: paymentId, id: paymentId,
}, },
include: { include: {
devices: true, devices: true,
}, },
}); });
return ( return (
<div> <div>
<div className="flex justify-between items-center border-[1px] rounded-md border-dashed font-bold title-bg py-4 px-2 mb-4"> <div className="flex justify-between items-center border-[1px] rounded-md border-dashed font-bold title-bg py-4 px-2 mb-4">
<h3 className="text-sarLinkOrange text-2xl"> <h3 className="text-sarLinkOrange text-2xl">Payment</h3>
Payment <span
</h3> className={cn(
<span className={cn("text-sm border px-4 py-2 rounded-md uppercase font-semibold", payment?.paid ? "text-green-500 bg-green-500/20" : "text-yellow-500 bg-yellow-700")}> "text-sm border px-4 py-2 rounded-md uppercase font-semibold",
{payment?.paid ? "Paid" : "Pending"} payment?.paid
</span> ? "text-green-500 bg-green-500/20"
</div> : "text-yellow-500 bg-yellow-700",
)}
>
{payment?.paid ? "Paid" : "Pending"}
</span>
</div>
<div <div
id="user-filters" id="user-filters"
className="pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start" className="pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
> >
<DevicesToPay <DevicesToPay user={user || undefined} payment={payment || undefined} />
user={user || undefined} </div>
payment={payment || undefined} </div>
/> );
</div>
</div>
);
} }

View File

@ -1,4 +1,4 @@
import { auth } from "@/lib/auth"; import { auth } from "@/app/auth";
import { toNextJsHandler } from "better-auth/next-js"; import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth.handler); export const { GET, POST } = toNextJsHandler(auth.handler);

108
app/auth.ts Normal file
View File

@ -0,0 +1,108 @@
import { logout } from "@/queries/authentication";
import type { NextAuthOptions } from "next-auth";
import type { JWT } from "next-auth/jwt";
import CredentialsProvider from "next-auth/providers/credentials";
export const authOptions: NextAuthOptions = {
pages: {
signIn: "/auth/signin",
},
session: {
strategy: "jwt",
maxAge: 30 * 60, // 30 mins
},
events: {
signOut({ token }) {
const apitoken = token.apiToken;
console.log("apitoken", apitoken);
logout({ token: apitoken as string });
},
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text", placeholder: "jsmith" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const { email, password } = credentials as {
email: string;
password: string;
};
console.log("email and password", email, password);
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/auth/login/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: email,
password: password,
}),
},
);
console.log("status", res.status);
const data = await res.json();
console.log({ data });
switch (res.status) {
case 200:
return { ...data.user, apiToken: data.token, expiry: data.expiry };
case 400:
throw new Error(
JSON.stringify({ message: data.message, status: res.status }),
);
case 429:
throw new Error(
JSON.stringify({ message: data.message, status: res.status }),
);
case 403:
throw new Error(
JSON.stringify({ message: data.error, status: res.status }),
);
default:
throw new Error(
JSON.stringify({
message: "FATAL: Unexprted Error occured!",
status: res.status,
}),
);
}
},
}),
],
callbacks: {
redirect: async ({ url, baseUrl }) => {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
return baseUrl;
},
session: async ({ session, token }) => {
const sanitizedToken = Object.keys(token).reduce((p, c) => {
// strip unnecessary properties
if (c !== "iat" && c !== "exp" && c !== "jti" && c !== "apiToken") {
Object.assign(p, { [c]: token[c] });
}
return p;
}, {});
// session.expires = token.expiry
return {
...session,
user: sanitizedToken,
apiToken: token.apiToken,
// expires: token.expiry,
};
},
jwt: ({ token, user }) => {
if (typeof user !== "undefined") {
// user has just signed in so the user object is populated
return user as unknown as JWT;
}
return token;
},
},
secret: process.env.NEXTAUTH_SECRET,
};

View File

@ -1,14 +1,14 @@
import { import {
Table, Table,
TableBody, TableBody,
TableCaption, TableCaption,
TableCell, TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { auth } from "@/lib/auth"; import { auth } from "@/app/auth";
import prisma from "@/lib/db"; import prisma from "@/lib/db";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Link from "next/link"; import Link from "next/link";
@ -17,180 +17,188 @@ import DeviceCard from "../device-card";
import Pagination from "../pagination"; import Pagination from "../pagination";
export async function AdminDevicesTable({ export async function AdminDevicesTable({
searchParams, searchParams,
parentalControl, parentalControl,
}: { }: {
searchParams: Promise<{ searchParams: Promise<{
query: string; query: string;
page: number; page: number;
sortBy: string; sortBy: string;
}>; }>;
parentalControl?: boolean; parentalControl?: boolean;
}) { }) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers(),
}) });
const isAdmin = session?.user.role === "ADMIN" const isAdmin = session?.user.role === "ADMIN";
const query = (await searchParams)?.query || ""; const query = (await searchParams)?.query || "";
const page = (await searchParams)?.page; const page = (await searchParams)?.page;
const sortBy = (await searchParams)?.sortBy || "asc"; const sortBy = (await searchParams)?.sortBy || "asc";
const totalDevices = await prisma.device.count({ const totalDevices = await prisma.device.count({
where: { where: {
OR: [ OR: [
{ {
name: { name: {
contains: query || "", contains: query || "",
mode: "insensitive", mode: "insensitive",
}, },
}, },
{ {
mac: { mac: {
contains: query || "", contains: query || "",
mode: "insensitive", mode: "insensitive",
}, },
}, },
], ],
}, },
}); });
const totalPages = Math.ceil(totalDevices / 10); const totalPages = Math.ceil(totalDevices / 10);
const limit = 10; const limit = 10;
const offset = (Number(page) - 1) * limit || 0; const offset = (Number(page) - 1) * limit || 0;
const devices = await prisma.device.findMany({ const devices = await prisma.device.findMany({
where: { where: {
OR: [ OR: [
{ {
name: { name: {
contains: query || "", contains: query || "",
mode: "insensitive", mode: "insensitive",
}, },
}, },
{ {
mac: { mac: {
contains: query || "", contains: query || "",
mode: "insensitive", mode: "insensitive",
}, },
}, },
], ],
}, },
include: { include: {
User: true, User: true,
payments: true, payments: true,
}, },
skip: offset, skip: offset,
take: limit, take: limit,
orderBy: { orderBy: {
name: `${sortBy}` as "asc" | "desc", name: `${sortBy}` as "asc" | "desc",
}, },
}); });
return ( return (
<div> <div>
{devices.length === 0 ? ( {devices.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4"> <div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
<h3>No devices yet.</h3> <h3>No devices yet.</h3>
</div> </div>
) : ( ) : (
<> <>
<div className="hidden sm:block"> <div className="hidden sm:block">
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableCaption>Table of all devices.</TableCaption> <TableCaption>Table of all devices.</TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Device Name</TableHead> <TableHead>Device Name</TableHead>
<TableHead>User</TableHead> <TableHead>User</TableHead>
<TableHead>MAC Address</TableHead> <TableHead>MAC Address</TableHead>
<TableHead>isActive</TableHead> <TableHead>isActive</TableHead>
<TableHead>blocked</TableHead> <TableHead>blocked</TableHead>
<TableHead>blockedBy</TableHead> <TableHead>blockedBy</TableHead>
<TableHead>expiryDate</TableHead> <TableHead>expiryDate</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="overflow-scroll"> <TableBody className="overflow-scroll">
{devices.map((device) => ( {devices.map((device) => (
<TableRow key={device.id}> <TableRow key={device.id}>
<TableCell> <TableCell>
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<Link <Link
className="font-medium hover:underline" className="font-medium hover:underline"
href={`/devices/${device.id}`} href={`/devices/${device.id}`}
> >
{device.name} {device.name}
</Link> </Link>
{device.isActive && ( {device.isActive && (
<span className="text-muted-foreground">
Active until{" "}
{new Date().toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
)}
<span className="text-muted-foreground"> {device.blocked && (
Active until{" "} <div className="p-2 rounded border my-2">
{new Date().toLocaleDateString("en-US", { <span>Comment: </span>
month: "short", <p className="text-neutral-500">
day: "2-digit", blocked because he was watching youtube
year: "numeric", </p>
})} </div>
</span> )}
)} </div>
</TableCell>
<TableCell className="font-medium">
{device.User?.name}
</TableCell>
{device.blocked && ( <TableCell className="font-medium">{device.mac}</TableCell>
<div className="p-2 rounded border my-2"> <TableCell>
<span>Comment: </span> {device.isActive ? "Active" : "Inactive"}
<p className="text-neutral-500"> </TableCell>
blocked because he was watching youtube <TableCell>
</p> {device.blocked ? "Blocked" : "Not Blocked"}
</div> </TableCell>
)} <TableCell>
{device.blocked ? device.blockedBy : ""}
</div> </TableCell>
</TableCell> <TableCell>
<TableCell className="font-medium">{device.User?.name}</TableCell> {new Date().toLocaleDateString("en-US", {
month: "short",
<TableCell className="font-medium">{device.mac}</TableCell> day: "2-digit",
<TableCell> year: "numeric",
{device.isActive ? "Active" : "Inactive"} })}
</TableCell> </TableCell>
<TableCell> <TableCell>
{device.blocked ? "Blocked" : "Not Blocked"} <BlockDeviceDialog
</TableCell> admin={isAdmin}
<TableCell> type={device.blocked ? "unblock" : "block"}
{device.blocked ? device.blockedBy : ""} device={device}
</TableCell> />
<TableCell> </TableCell>
{new Date().toLocaleDateString("en-US", { </TableRow>
month: "short", ))}
day: "2-digit", </TableBody>
year: "numeric", <TableFooter>
})} <TableRow>
</TableCell> <TableCell colSpan={7}>
<TableCell> {query.length > 0 && (
<BlockDeviceDialog admin={isAdmin} type={device.blocked ? "unblock" : "block"} device={device} /> <p className="text-sm text-muted-foreground">
</TableCell> Showing {devices.length} locations for &quot;{query}
</TableRow> &quot;
))} </p>
</TableBody> )}
<TableFooter> </TableCell>
<TableRow> <TableCell className="text-muted-foreground">
<TableCell colSpan={7}> {totalDevices} devices
{query.length > 0 && ( </TableCell>
<p className="text-sm text-muted-foreground"> </TableRow>
Showing {devices.length} locations for &quot;{query} </TableFooter>
&quot; </Table>
</p> <Pagination totalPages={totalPages} currentPage={page} />
)} </div>
</TableCell> <div className="sm:hidden my-4">
<TableCell className="text-muted-foreground"> {devices.map((device) => (
{totalDevices} devices <DeviceCard
</TableCell> parentalControl={parentalControl}
</TableRow> key={device.id}
</TableFooter> device={device}
</Table> />
<Pagination totalPages={totalPages} currentPage={page} /> ))}
</div> </div>
<div className="sm:hidden my-4"> </>
{devices.map((device) => ( )}
<DeviceCard parentalControl={parentalControl} key={device.id} device={device} /> </div>
))} );
</div>
</>
)}
</div>
);
} }

View File

@ -10,7 +10,7 @@ import {
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { auth } from "@/lib/auth"; import { auth } from "@/app/auth";
import prisma from "@/lib/db"; import prisma from "@/lib/db";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { AccountPopover } from "./account-popver"; import { AccountPopover } from "./account-popver";
@ -19,7 +19,7 @@ export async function ApplicationLayout({
children, children,
}: { children: React.ReactNode }) { }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers(),
}); });
const billFormula = await prisma.billFormula.findFirst(); const billFormula = await prisma.billFormula.findFirst();
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({

View File

@ -23,6 +23,7 @@ export default function LoginForm() {
<PhoneInput <PhoneInput
id="phone-number" id="phone-number"
name="phoneNumber" name="phoneNumber"
className="b0rder"
maxLength={8} maxLength={8}
disabled={isPending} disabled={isPending}
placeholder="Enter phone number" placeholder="Enter phone number"
@ -32,11 +33,7 @@ export default function LoginForm() {
{state.status === "error" && ( {state.status === "error" && (
<p className="text-red-500 text-sm">{state.message}</p> <p className="text-red-500 text-sm">{state.message}</p>
)} )}
<Button <Button className="" disabled={isPending} type="submit">
className=""
disabled={isPending}
type="submit"
>
{isPending ? <Loader2 className="animate-spin" /> : "Request OTP"} {isPending ? <Loader2 className="animate-spin" /> : "Request OTP"}
</Button> </Button>
</div> </div>

View File

@ -8,7 +8,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { auth } from "@/lib/auth"; import { auth } from "@/app/auth";
import prisma from "@/lib/db"; import prisma from "@/lib/db";
import { headers } from "next/headers"; import { headers } from "next/headers";
import ClickableRow from "./clickable-row"; import ClickableRow from "./clickable-row";
@ -27,9 +27,9 @@ export async function DevicesTable({
parentalControl?: boolean; parentalControl?: boolean;
}) { }) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers(),
}) });
const isAdmin = session?.user.role === "ADMIN" const isAdmin = session?.user.role === "ADMIN";
const query = (await searchParams)?.query || ""; const query = (await searchParams)?.query || "";
const page = (await searchParams)?.page; const page = (await searchParams)?.page;
const sortBy = (await searchParams)?.sortBy || "asc"; const sortBy = (await searchParams)?.sortBy || "asc";
@ -53,12 +53,16 @@ export async function DevicesTable({
NOT: { NOT: {
payments: { payments: {
some: { some: {
paid: false paid: false,
} },
} },
}, },
isActive: isAdmin ? undefined : parentalControl, isActive: isAdmin ? undefined : parentalControl,
blocked: isAdmin ? undefined : parentalControl !== undefined ? undefined : false, blocked: isAdmin
? undefined
: parentalControl !== undefined
? undefined
: false,
}, },
}); });
@ -86,8 +90,8 @@ export async function DevicesTable({
NOT: { NOT: {
payments: { payments: {
some: { some: {
paid: false paid: false,
} },
}, },
}, },
isActive: parentalControl, isActive: parentalControl,
@ -158,7 +162,12 @@ export async function DevicesTable({
// )} // )}
// </TableCell> // </TableCell>
// </TableRow> // </TableRow>
<ClickableRow admin={isAdmin} key={device.id} device={device} parentalControl={parentalControl} /> <ClickableRow
admin={isAdmin}
key={device.id}
device={device}
parentalControl={parentalControl}
/>
))} ))}
</TableBody> </TableBody>
<TableFooter> <TableFooter>
@ -181,7 +190,11 @@ export async function DevicesTable({
</div> </div>
<div className="sm:hidden my-4"> <div className="sm:hidden my-4">
{devices.map((device) => ( {devices.map((device) => (
<DeviceCard parentalControl={parentalControl} key={device.id} device={device} /> <DeviceCard
parentalControl={parentalControl}
key={device.id}
device={device}
/>
))} ))}
</div> </div>
</> </>

View File

@ -1,17 +1,17 @@
import { import {
Table, Table,
TableBody, TableBody,
TableCaption, TableCaption,
TableCell, TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import prisma from "@/lib/db"; import prisma from "@/lib/db";
import Link from "next/link"; import Link from "next/link";
import { auth } from "@/lib/auth"; import { auth } from "@/app/auth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
@ -22,219 +22,258 @@ import { Button } from "./ui/button";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
type PaymentWithDevices = Prisma.PaymentGetPayload<{ type PaymentWithDevices = Prisma.PaymentGetPayload<{
include: { include: {
devices: true; devices: true;
}; };
}> }>;
export async function PaymentsTable({ export async function PaymentsTable({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ searchParams: Promise<{
query: string; query: string;
page: number; page: number;
sortBy: string; sortBy: string;
}>; }>;
}) { }) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers(),
}) });
const query = (await searchParams)?.query || ""; const query = (await searchParams)?.query || "";
const page = (await searchParams)?.page; const page = (await searchParams)?.page;
const totalPayments = await prisma.payment.count({ const totalPayments = await prisma.payment.count({
where: { where: {
userId: session?.session.userId, userId: session?.session.userId,
OR: [ OR: [
{ {
devices: { devices: {
every: { every: {
name: { name: {
contains: query || "", contains: query || "",
mode: "insensitive", mode: "insensitive",
}, },
}, },
}, },
}, },
], ],
}, },
}); });
const totalPages = Math.ceil(totalPayments / 10); const totalPages = Math.ceil(totalPayments / 10);
const limit = 10; const limit = 10;
const offset = (Number(page) - 1) * limit || 0; const offset = (Number(page) - 1) * limit || 0;
const payments = await prisma.payment.findMany({ const payments = await prisma.payment.findMany({
where: { where: {
userId: session?.session.userId, userId: session?.session.userId,
OR: [ OR: [
{ {
devices: { devices: {
every: { every: {
name: { name: {
contains: query || "", contains: query || "",
mode: "insensitive", mode: "insensitive",
}, },
}, },
}, },
}, },
], ],
}, },
include: { include: {
devices: true devices: true,
}, },
skip: offset, skip: offset,
take: limit, take: limit,
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
}, },
}); });
return ( return (
<div> <div>
{payments.length === 0 ? ( {payments.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4"> <div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
<h3>No Payments yet.</h3> <h3>No Payments yet.</h3>
</div> </div>
) : ( ) : (
<> <>
<div className="hidden sm:block"> <div className="hidden sm:block">
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableCaption>Table of all devices.</TableCaption> <TableCaption>Table of all devices.</TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Details</TableHead> <TableHead>Details</TableHead>
<TableHead>Duration</TableHead> <TableHead>Duration</TableHead>
<TableHead>Amount</TableHead> <TableHead>Amount</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="overflow-scroll"> <TableBody className="overflow-scroll">
{payments.map((payment) => ( {payments.map((payment) => (
<TableRow key={payment.id}> <TableRow key={payment.id}>
<TableCell> <TableCell>
<div className={cn("flex flex-col items-start border rounded p-2", payment?.paid ? "bg-green-500/10 border-dashed border-green=500" : "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50")}> <div
<div className="flex items-center gap-2"> className={cn(
<Calendar size={16} opacity={0.5} /> "flex flex-col items-start border rounded p-2",
<span className="text-muted-foreground"> payment?.paid
{new Date(payment.createdAt).toLocaleDateString("en-US", { ? "bg-green-500/10 border-dashed border-green=500"
month: "short", : "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
day: "2-digit", )}
year: "numeric", >
})} <div className="flex items-center gap-2">
</span> <Calendar size={16} opacity={0.5} />
</div> <span className="text-muted-foreground">
{new Date(payment.createdAt).toLocaleDateString(
"en-US",
{
month: "short",
day: "2-digit",
year: "numeric",
},
)}
</span>
</div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Link className="font-medium hover:underline" href={`/payments/${payment.id}`}> <Link
<Button size={"sm"} variant="outline"> className="font-medium hover:underline"
View Details href={`/payments/${payment.id}`}
</Button> >
</Link> <Button size={"sm"} variant="outline">
<Badge className={cn(payment?.paid ? "text-green-500 bg-green-500/20" : "text-yellow-500 bg-yellow-500/20")} variant={payment.paid ? "outline" : "secondary"}> View Details
{payment.paid ? "Paid" : "Unpaid"} </Button>
</Badge> </Link>
</div> <Badge
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border"> className={cn(
<h3 className="text-sm font-medium">Devices</h3> payment?.paid
<ol className="list-disc list-inside text-sm"> ? "text-green-500 bg-green-500/20"
{payment.devices.map((device) => ( : "text-yellow-500 bg-yellow-500/20",
<li key={device.id} className="text-sm text-muted-foreground"> )}
{device.name} variant={payment.paid ? "outline" : "secondary"}
</li> >
))} {payment.paid ? "Paid" : "Unpaid"}
</ol> </Badge>
</div> </div>
</div> <div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border">
</TableCell> <h3 className="text-sm font-medium">Devices</h3>
<TableCell className="font-medium" > <ol className="list-disc list-inside text-sm">
{payment.numberOfMonths} Months {payment.devices.map((device) => (
</TableCell> <li
<TableCell> key={device.id}
<span className="font-semibold pr-2"> className="text-sm text-muted-foreground"
{payment.amount.toFixed(2)} >
</span> {device.name}
MVR </li>
</TableCell> ))}
</TableRow> </ol>
))} </div>
</TableBody> </div>
<TableFooter> </TableCell>
<TableRow> <TableCell className="font-medium">
<TableCell colSpan={2}> {payment.numberOfMonths} Months
{query.length > 0 && ( </TableCell>
<p className="text-sm text-muted-foreground"> <TableCell>
Showing {payments.length} locations for &quot;{query} <span className="font-semibold pr-2">
&quot; {payment.amount.toFixed(2)}
</p> </span>
)} MVR
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> </TableRow>
{totalPayments} payments ))}
</TableCell> </TableBody>
</TableRow> <TableFooter>
</TableFooter> <TableRow>
</Table> <TableCell colSpan={2}>
<Pagination totalPages={totalPages} currentPage={page} /> {query.length > 0 && (
</div> <p className="text-sm text-muted-foreground">
<div className="sm:hidden block"> Showing {payments.length} locations for &quot;{query}
{payments.map((payment) => ( &quot;
<MobilePaymentDetails key={payment.id} payment={payment} /> </p>
))} )}
</div> </TableCell>
</> <TableCell className="text-muted-foreground">
)} {totalPayments} payments
</div> </TableCell>
); </TableRow>
</TableFooter>
</Table>
<Pagination totalPages={totalPages} currentPage={page} />
</div>
<div className="sm:hidden block">
{payments.map((payment) => (
<MobilePaymentDetails key={payment.id} payment={payment} />
))}
</div>
</>
)}
</div>
);
} }
function MobilePaymentDetails({ payment }: { payment: PaymentWithDevices }) { function MobilePaymentDetails({ payment }: { payment: PaymentWithDevices }) {
return ( return (
<div className={cn("flex flex-col items-start border rounded p-2", payment?.paid ? "bg-green-500/10 border-dashed border-green=500" : "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50")}> <div
<div className="flex items-center gap-2"> className={cn(
<Calendar size={16} opacity={0.5} /> "flex flex-col items-start border rounded p-2",
<span className="text-muted-foreground text-sm"> payment?.paid
{new Date(payment.createdAt).toLocaleDateString("en-US", { ? "bg-green-500/10 border-dashed border-green=500"
month: "short", : "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
day: "2-digit", )}
year: "numeric", >
})} <div className="flex items-center gap-2">
</span> <Calendar size={16} opacity={0.5} />
</div> <span className="text-muted-foreground text-sm">
{new Date(payment.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
</div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Link className="font-medium hover:underline" href={`/payments/${payment.id}`}> <Link
<Button size={"sm"} variant="outline"> className="font-medium hover:underline"
View Details href={`/payments/${payment.id}`}
</Button> >
</Link> <Button size={"sm"} variant="outline">
<Badge className={cn(payment?.paid ? "text-green-500 bg-green-500/20" : "text-yellow-500 bg-yellow-500/20")} variant={payment.paid ? "outline" : "secondary"}> View Details
{payment.paid ? "Paid" : "Unpaid"} </Button>
</Badge> </Link>
</div> <Badge
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border"> className={cn(
<h3 className="text-sm font-medium">Devices</h3> payment?.paid
<ol className="list-disc list-inside text-sm"> ? "text-green-500 bg-green-500/20"
{payment.devices.map((device) => ( : "text-yellow-500 bg-yellow-500/20",
<li key={device.id} className="text-sm text-muted-foreground"> )}
{device.name} variant={payment.paid ? "outline" : "secondary"}
</li> >
))} {payment.paid ? "Paid" : "Unpaid"}
</ol> </Badge>
<div className="block sm:hidden"> </div>
<Separator className="my-2" /> <div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border">
<h3 className="text-sm font-medium">Duration</h3> <h3 className="text-sm font-medium">Devices</h3>
<span className="text-sm text-muted-foreground"> <ol className="list-disc list-inside text-sm">
{payment.numberOfMonths} Months {payment.devices.map((device) => (
</span> <li key={device.id} className="text-sm text-muted-foreground">
<Separator className="my-2" /> {device.name}
<h3 className="text-sm font-medium">Amount</h3> </li>
<span className="text-sm text-muted-foreground"> ))}
{payment.amount.toFixed(2)} MVR </ol>
</span> <div className="block sm:hidden">
</div> <Separator className="my-2" />
</div> <h3 className="text-sm font-medium">Duration</h3>
</div> <span className="text-sm text-muted-foreground">
) {payment.numberOfMonths} Months
</span>
<Separator className="my-2" />
<h3 className="text-sm font-medium">Amount</h3>
<span className="text-sm text-muted-foreground">
{payment.amount.toFixed(2)} MVR
</span>
</div>
</div>
</div>
);
} }

View File

@ -5,164 +5,160 @@ import flags from "react-phone-number-input/flags";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type PhoneInputProps = Omit< type PhoneInputProps = Omit<
React.ComponentProps<"input">, React.ComponentProps<"input">,
"onChange" | "value" | "ref" "onChange" | "value" | "ref"
> & > &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void; onChange?: (value: RPNInput.Value) => void;
}; };
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>( React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
({ className, onChange, ...props }, ref) => { ({ className, onChange, ...props }, ref) => {
return ( return (
<RPNInput.default <RPNInput.default
ref={ref} ref={ref}
className={cn("flex", className)} className={cn("flex", className)}
flagComponent={FlagComponent} flagComponent={FlagComponent}
countrySelectComponent={CountrySelect} countrySelectComponent={CountrySelect}
inputComponent={InputComponent} inputComponent={InputComponent}
smartCaret={false} smartCaret={false}
/** /**
* Handles the onChange event. * Handles the onChange event.
* *
* react-phone-number-input might trigger the onChange event as undefined * react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this, * when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string. * the value is coerced to an empty string.
* *
* @param {E164Number | undefined} value - The entered value * @param {E164Number | undefined} value - The entered value
*/ */
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props} {...props}
/> />
); );
}, },
); );
PhoneInput.displayName = "PhoneInput"; PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef< const InputComponent = React.forwardRef<
HTMLInputElement, HTMLInputElement,
React.ComponentProps<"input"> React.ComponentProps<"input">
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<Input <Input className={cn("mx-2", className)} {...props} ref={ref} />
className={cn("rounded-e-lg rounded-s-none", className)}
{...props}
ref={ref}
/>
)); ));
InputComponent.displayName = "InputComponent"; InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined }; type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = { type CountrySelectProps = {
disabled?: boolean; disabled?: boolean;
value: RPNInput.Country; value: RPNInput.Country;
options: CountryEntry[]; options: CountryEntry[];
onChange: (country: RPNInput.Country) => void; onChange: (country: RPNInput.Country) => void;
}; };
const CountrySelect = ({ const CountrySelect = ({
disabled, disabled,
value: selectedCountry, value: selectedCountry,
options: countryList, options: countryList,
onChange, onChange,
}: CountrySelectProps) => { }: CountrySelectProps) => {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10" className="flex gap-1 px-3 focus:z-10"
disabled={true} disabled={true}
> >
<FlagComponent <FlagComponent
country={selectedCountry} country={selectedCountry}
countryName={selectedCountry} countryName={selectedCountry}
/> />
<ChevronsUpDown <ChevronsUpDown
className={cn( className={cn(
"-mr-2 size-4 opacity-50", "-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100", disabled ? "hidden" : "opacity-100",
)} )}
/> />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[300px] p-0"> <PopoverContent className="w-[300px] p-0">
<Command> <Command>
<CommandInput placeholder="Search country..." /> <CommandInput placeholder="Search country..." />
<CommandList> <CommandList>
<ScrollArea className="h-72"> <ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty> <CommandEmpty>No country found.</CommandEmpty>
<CommandGroup> <CommandGroup>
{countryList.map(({ value, label }) => {countryList.map(({ value, label }) =>
value ? ( value ? (
<CountrySelectOption <CountrySelectOption
key={value} key={value}
country={value} country={value}
countryName={label} countryName={label}
selectedCountry={selectedCountry} selectedCountry={selectedCountry}
onChange={onChange} onChange={onChange}
/> />
) : null, ) : null,
)} )}
</CommandGroup> </CommandGroup>
</ScrollArea> </ScrollArea>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}; };
interface CountrySelectOptionProps extends RPNInput.FlagProps { interface CountrySelectOptionProps extends RPNInput.FlagProps {
selectedCountry: RPNInput.Country; selectedCountry: RPNInput.Country;
onChange: (country: RPNInput.Country) => void; onChange: (country: RPNInput.Country) => void;
} }
const CountrySelectOption = ({ const CountrySelectOption = ({
country, country,
countryName, countryName,
selectedCountry, selectedCountry,
onChange, onChange,
}: CountrySelectOptionProps) => { }: CountrySelectOptionProps) => {
return ( return (
<CommandItem className="gap-2" onSelect={() => onChange(country)}> <CommandItem className="gap-2" onSelect={() => onChange(country)}>
<FlagComponent country={country} countryName={countryName} /> <FlagComponent country={country} countryName={countryName} />
<span className="flex-1 text-sm">{countryName}</span> <span className="flex-1 text-sm">{countryName}</span>
<span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span> <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
<CheckIcon <CheckIcon
className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`} className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
/> />
</CommandItem> </CommandItem>
); );
}; };
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country]; const Flag = flags[country];
return ( return (
<span className="flex scale-125 h-4 w-6 overflow-hidden rounded-sm cursor-not-allowed"> <span className="flex scale-125 h-4 w-6 overflow-hidden rounded-sm cursor-not-allowed">
{Flag && <Flag title={countryName} />} {Flag && <Flag title={countryName} />}
</span> </span>
); );
}; };
export { PhoneInput }; export { PhoneInput };

View File

@ -1,7 +0,0 @@
import { phoneNumberClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL,
plugins: [phoneNumberClient()],
});

View File

@ -1,5 +1,5 @@
"use server"; "use server";
import { auth } from "@/lib/auth"; import { auth } from "@/app/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";

View File

@ -1,7 +1,7 @@
"use server"; "use server";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { cache } from "react"; import { cache } from "react";
import { auth } from "./auth"; import { auth } from "../app/auth";
const getCurrentUserCache = cache(async () => { const getCurrentUserCache = cache(async () => {
const session = await auth.api.getSession({ const session = await auth.api.getSession({

View File

@ -1,44 +0,0 @@
import { sendOtp } from "@/actions/auth-actions";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { phoneNumber } from "better-auth/plugins";
import prisma from "./db";
export const auth = betterAuth({
session: {
cookieCache: {
enabled: true,
maxAge: 10 * 60, // Cache duration in seconds
},
},
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") || [
"localhost:3000",
],
user: {
additionalFields: {
role: {
type: "string",
required: false,
defaultValue: "USER",
input: false, // don't allow user to set role
},
lang: {
type: "string",
required: false,
defaultValue: "en",
},
},
},
database: prismaAdapter(prisma, {
provider: "postgresql", // or "mysql", "postgresql", ...etc
}),
plugins: [
phoneNumber({
sendOTP: async ({ phoneNumber, code }) => {
// Implement sending OTP code via SMS
console.log("Send OTP in auth.ts", phoneNumber, code);
await sendOtp(phoneNumber, code);
},
}),
],
});

View File

@ -1,17 +0,0 @@
import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = () => {
return new PrismaClient();
};
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};
const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

38
lib/types/user.ts Normal file
View File

@ -0,0 +1,38 @@
import type { ISODateString } from "next-auth";
export interface Permission {
id: number;
name: string;
user: User;
}
export interface TAuthUser {
expiry?: string;
token?: string;
user: User;
}
export interface User {
id: number;
username: string;
email: string;
user_permissions: Permission[];
first_name: string;
last_name: string;
is_superuser: boolean;
date_joined: string;
last_login: string;
}
export interface Session {
user?: {
token?: string;
name?: string | null;
email?: string | null;
image?: string | null;
user?: User & {
expiry?: string;
};
};
expires: ISODateString;
}

745
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,6 @@
"dependencies": { "dependencies": {
"@faker-js/faker": "^9.3.0", "@faker-js/faker": "^9.3.0",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@prisma/client": "^6.1.0",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.1",
@ -31,7 +30,7 @@
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.61.4", "@tanstack/react-query": "^5.61.4",
"better-auth": "^1.1.13", "axios": "^1.8.4",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
@ -41,12 +40,12 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "15.1.2", "next": "15.1.2",
"next-auth": "^4.24.11",
"next-themes": "^0.4.3", "next-themes": "^0.4.3",
"nextjs-toploader": "^3.7.15", "nextjs-toploader": "^3.7.15",
"prisma": "^6.1.0",
"react": "19.0.0", "react": "19.0.0",
"react-aria-components": "^1.5.0", "react-aria-components": "^1.5.0",
"react-day-picker": "^8.10.1", "react-day-picker": "^9.6.3",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-hook-form": "^7.53.2", "react-hook-form": "^7.53.2",
"react-phone-number-input": "^3.4.9", "react-phone-number-input": "^3.4.9",

View File

@ -1,10 +0,0 @@
"use server";
import type { Atoll, DataResponse } from "@/lib/backend-types";
export async function getAtollsWithIslands(): Promise<DataResponse<Atoll>> {
const response = await fetch(
`${process.env.SARLINK_API_BASE_URL}/api/auth/atolls`,
);
return response.json();
}

48
queries/authentication.ts Normal file
View File

@ -0,0 +1,48 @@
"use server";
import type { TAuthUser } from "@/lib/types/user";
import axiosInstance from "@/utils/axiosInstance";
export async function login({
password,
username,
}: {
username: string;
password: string;
}): Promise<TAuthUser> {
const response = await axiosInstance
.post("/auth/login/", {
username: username,
password: password,
})
.then((res) => {
console.log(res);
return res.data; // Return the data from the response
})
.catch((err) => {
console.log(err.response);
throw err; // Throw the error to maintain the Promise rejection
});
return response;
}
export async function logout({ token }: { token: string }) {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/auth/logout/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Token ${token}`, // Include the token for authentication
},
},
);
if (response.status !== 204) {
throw new Error("Failed to log out from the backend");
}
console.log("logout res in backend", response);
// Since the API endpoint returns 204 No Content on success, we don't need to parse JSON
return null; // Return null to indicate a successful logout with no content
}

13
utils/axiosInstance.ts Normal file
View File

@ -0,0 +1,13 @@
import axios from "axios";
axios.defaults.xsrfCookieName = "csrftoken";
axios.defaults.xsrfHeaderName = "X-CSRFToken";
const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
validateStatus: (status) => {
return status < 500; // Resolve only if the status code is less than 500
},
});
export default axiosInstance;