Implement parental control features and enhance device management

- Added a new Parental Control page for managing device access and notifications.
- Introduced blockDevice function to handle blocking and unblocking devices based on payment status.
- Enhanced omada-actions.ts to include device blocking logic and improved error handling.
- Updated DevicesTable component to integrate BlockDeviceButton for managing device states.
- Implemented API route for checking device statuses and sending notifications for expiring devices.
- Refactored payment processing to update device statuses upon successful payment verification.
- Added new utility functions for API key validation and SMS notifications.

These changes improve user control over device management and enhance the overall functionality of the application.
This commit is contained in:
i701 2024-12-22 21:34:57 +05:00
parent 586c0e7210
commit c06c4fee3f
17 changed files with 532 additions and 87 deletions

View File

@ -1,39 +1,9 @@
interface IpAddress {
ip: string;
mask: number;
}
"use server";
interface Ipv6Address {
ip: string;
prefix: number;
}
interface MacAddress {
ruleId?: number;
name: string;
macAddress: string;
}
interface GroupProfile {
groupId: string;
site?: string;
name: string;
buildIn?: boolean;
ipList?: IpAddress[];
ipv6List?: Ipv6Address[];
macAddressList?: MacAddress[];
count: number;
type: number;
resource: number;
}
interface OmadaResponse {
errorCode: number;
msg: string;
result: {
data: GroupProfile[];
};
}
import prisma from "@/lib/db";
import type { GroupProfile, MacAddress, OmadaResponse } from "@/lib/types";
import { formatMacAddress } from "@/lib/utils";
import { revalidatePath } from "next/cache";
async function fetchOmadaGroupProfiles(siteId: string): Promise<OmadaResponse> {
if (!siteId) {
@ -68,7 +38,7 @@ async function fetchOmadaGroupProfiles(siteId: string): Promise<OmadaResponse> {
}
}
export { fetchOmadaGroupProfiles, type MacAddress };
export { fetchOmadaGroupProfiles };
export async function addDevicesToGroup({
siteId,
@ -129,7 +99,7 @@ export async function addDevicesToGroup({
headers: headers,
body: JSON.stringify(requestBody),
});
console.log(response);
console.log(response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@ -138,3 +108,48 @@ export async function addDevicesToGroup({
throw error instanceof Error ? error : new Error("Unknown error occurred");
}
}
export async function blockDevice({
macAddress,
type,
}: { macAddress: string; type: "block" | "unblock" }) {
console.log("hello world asdasd");
if (!macAddress) {
throw new Error("macAddress is a required parameter");
}
const device = await prisma.device.findFirst({
where: {
mac: macAddress,
},
});
try {
const baseUrl: string = process.env.OMADA_BASE_URL || "";
const url: string = `${baseUrl}/api/v2/sites/${process.env.OMADA_SITE_ID}/cmd/clients/${formatMacAddress(macAddress)}/${type}`;
console.log(url);
const headers: HeadersInit = {
"X-API-key": process.env.OMADA_PROXY_API_KEY || "",
};
const response = await fetch(url, {
method: "POST",
headers: headers,
});
console.log("blocking...");
console.log(response);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
await prisma.device.update({
where: {
id: device?.id,
},
data: {
blocked: type === "block",
},
});
revalidatePath("/parental-control");
} catch (error) {
console.error("Error blocking device:", error);
throw error instanceof Error ? error : new Error("Unknown error occurred");
}
}

View File

@ -72,6 +72,17 @@ export async function verifyPayment(data: VerifyPaymentType) {
},
data: {
paid: true,
paidAt: new Date(),
devices: {
updateMany: {
where: {
paymentId: payment?.id,
},
data: {
isActive: true,
},
},
},
},
}),
]);

View File

@ -0,0 +1,56 @@
import { DevicesTable } from "@/components/devices-table";
import Filter from "@/components/filter";
import Search from "@/components/search";
import { AArrowDown, AArrowUp } from "lucide-react";
import React, { Suspense } from "react";
const sortfilterOptions = [
{
value: 'asc',
label: 'Ascending',
icon: <AArrowUp size={16} />,
},
{
value: 'desc',
label: 'Descending',
icon: <AArrowDown size={16} />,
},
]
export default async function ParentalControl({
searchParams,
}: {
searchParams: Promise<{
query: string;
page: number;
sortBy: string;
status: string;
}>;
}) {
const query = (await searchParams)?.query || "";
return (
<div>
<div className="flex justify-between items-center border-b-2 text-gray-500 text-2xl font-bold title-bg py-4 px-2 mb-4">
<h3>
Parental Control
</h3>
</div>
<div
id="user-filters"
className=" border-b-2 pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
>
<Search />
<Filter
options={sortfilterOptions}
defaultOption="asc"
queryParamKey="sortBy"
/>
</div>
<Suspense key={query} fallback={"loading...."}>
<DevicesTable parentalControl={true} searchParams={searchParams} />
</Suspense>
</div>
);
}

View File

@ -0,0 +1,145 @@
import { blockDevice } from "@/actions/omada-actions";
import prisma from "@/lib/db";
import { validateApiKey } from "@/lib/utils";
import { addDays, addMonths, isAfter, isWithinInterval } from "date-fns";
const lastRunTime = new Date();
export async function GET(request: Request) {
try {
// Validate API key before proceeding
validateApiKey(request);
const currentTime = new Date();
const hoursSinceLastRun =
(currentTime.getTime() - lastRunTime.getTime()) / (1000 * 60 * 60);
// Get all active and unblocked devices with their latest payment
const devices = await prisma.device.findMany({
where: {
isActive: true,
blocked: false,
},
include: {
payment: true,
User: true,
},
});
let devicesNeedingNotification = 0;
let devicesBlocked = 0;
for (const device of devices) {
let expiryDate = new Date();
const payment = device.payment;
expiryDate = addMonths(
payment?.paidAt || new Date(),
payment?.numberOfMonths || 0,
);
// Calculate notification threshold (5 days before expiry)
const notificationThreshold = addDays(expiryDate, -5);
const currentDate = new Date();
console.log("device name -> ", device.name);
console.log("paid date -> ", device.payment?.paidAt);
console.log("no of months paid -> ", device.payment?.numberOfMonths);
console.log("calculated expire date -> ", expiryDate);
console.log("notification threshold -> ", notificationThreshold);
console.log("current date -> ", currentDate);
// Check if device is within notification period
if (
isWithinInterval(currentDate, {
start: notificationThreshold,
end: expiryDate,
})
) {
// Device is within 5 days of expiring
if (device.User?.phoneNumber) {
await sendNotifySms(
new Date(expiryDate),
device.User.phoneNumber,
device.name,
);
devicesNeedingNotification++;
}
}
// Check if device has expired
if (isAfter(currentDate, expiryDate)) {
// Device has expired, block it
await prisma.device.update({
where: { id: device.id },
data: {
isActive: false,
blocked: true,
},
});
devicesBlocked++;
}
await blockDevice({
macAddress: device.mac,
type: "block",
});
}
if (hoursSinceLastRun < 24) {
return Response.json({
totalActiveDevices: devices.length,
devicesChecked: {
notified: devicesNeedingNotification,
blocked: devicesBlocked,
},
message: "Check was run recently",
nextCheckIn: `${Math.round(24 - hoursSinceLastRun)} hours`,
});
}
return Response.json({
success: true,
totalActiveDevices: devices.length,
devicesChecked: {
notified: devicesNeedingNotification,
blocked: devicesBlocked,
},
runAt: currentTime,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "API key is missing") {
return Response.json({ error: "API key is required" }, { status: 401 });
}
if (error.message === "Invalid API key") {
return Response.json({ error: "Invalid API key" }, { status: 403 });
}
}
console.error("Error in device check:", error);
return Response.json({ error: "Failed to check devices" }, { status: 500 });
}
}
// Mock function - replace with your actual SMS implementation
async function sendNotifySms(
expireDate: Date,
phoneNumber: string,
deviceName?: string,
) {
const respose = await fetch("https://smsapi.sarlink.link/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
api_key: process.env.SMS_API_KEY,
number: phoneNumber,
text: `REMINDER! Your device [${deviceName}] will expire on ${new Date(expireDate)}.`,
}),
});
const data = await respose.json();
console.log(data);
return data;
// Implement your SMS logic here
}

View File

@ -0,0 +1,34 @@
'use client'
import { blockDevice } from "@/actions/omada-actions";
import { Button } from "@/components/ui/button";
import type { Device } from "@prisma/client";
import { useState } from "react";
import { toast } from "sonner";
import { TextShimmer } from "./ui/text-shimmer";
export default function BlockDeviceButton({ device }: { device: Device }) {
const [disabled, setDisabled] = useState(false);
return (
<Button
disabled={disabled}
onClick={() => {
setDisabled(true);
toast.promise(blockDevice({ macAddress: device.mac, type: device.blocked ? "unblock" : "block" }), {
loading: device.blocked ? "Unblocking..." : "Blocking...",
success: () => {
setDisabled(false);
return `Device ${device.name} successfully ${device.blocked ? "unblocked" : "blocked"
}!`;
},
error: () => {
setDisabled(false);
return "Something went wrong";
},
});
}}
>
{disabled ? <TextShimmer>{device.blocked ? "Unblocking..." : "Blocking..."}</TextShimmer> : (device?.blocked ? "Unblock" : "Block")}
</Button>
)
}

View File

@ -8,25 +8,34 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { auth } from "@/lib/auth";
import prisma from "@/lib/db";
import { headers } from "next/headers";
import Link from "next/link";
import AddDevicesToCartButton from "./add-devices-to-cart-button";
import BlockDeviceButton from "./block-device-button";
import Pagination from "./pagination";
export async function DevicesTable({
searchParams,
parentalControl
}: {
searchParams: Promise<{
query: string;
page: number;
sortBy: string;
}>;
parentalControl?: boolean;
}) {
const session = await auth.api.getSession({
headers: await headers()
})
const query = (await searchParams)?.query || "";
const page = (await searchParams)?.page;
const sortBy = (await searchParams)?.sortBy || "asc";
const totalDevices = await prisma.device.count({
where: {
userId: session?.session.userId,
OR: [
{
name: {
@ -46,6 +55,7 @@ export async function DevicesTable({
paid: false
}
},
isActive: parentalControl ? parentalControl : undefined,
},
});
@ -55,6 +65,7 @@ export async function DevicesTable({
const devices = await prisma.device.findMany({
where: {
userId: session?.session.userId,
OR: [
{
name: {
@ -74,6 +85,7 @@ export async function DevicesTable({
paid: false
}
},
isActive: parentalControl,
},
skip: offset,
@ -123,7 +135,11 @@ export async function DevicesTable({
</TableCell>
<TableCell className="font-medium">{device.mac}</TableCell>
<TableCell>
<AddDevicesToCartButton device={device} />
{!parentalControl ? (
<AddDevicesToCartButton device={device} />
) : (
<BlockDeviceButton device={device} />
)}
</TableCell>
</TableRow>
))}

View File

@ -11,8 +11,10 @@ import {
import prisma from "@/lib/db";
import Link from "next/link";
import { auth } from "@/lib/auth";
import { cn } from "@/lib/utils";
import { Calendar } from "lucide-react";
import { headers } from "next/headers";
import Pagination from "./pagination";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
@ -26,10 +28,14 @@ export async function PaymentsTable({
sortBy: string;
}>;
}) {
const session = await auth.api.getSession({
headers: await headers()
})
const query = (await searchParams)?.query || "";
const page = (await searchParams)?.page;
const totalPayments = await prisma.payment.count({
where: {
userId: session?.session.userId,
OR: [
{
devices: {
@ -51,6 +57,7 @@ export async function PaymentsTable({
const payments = await prisma.payment.findMany({
where: {
userId: session?.session.userId,
OR: [
{
devices: {

View File

@ -40,12 +40,16 @@ const data = {
url: "/devices",
icon: <Smartphone size={16} />,
},
{
title: "Payments",
url: "/payments",
icon: <CreditCard size={16} />,
},
{
title: "Parental Control",
url: "/parental-control",
icon: <CreditCard size={16} />,
},
{
title: "Agreements",
url: "/agreements",

View File

@ -0,0 +1,55 @@
'use client';
import React, { useMemo, type JSX } from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
interface TextShimmerProps {
children: string;
as?: React.ElementType;
className?: string;
duration?: number;
spread?: number;
}
export function TextShimmer({
children,
as: Component = 'p',
className,
duration = 2,
spread = 2,
}: TextShimmerProps) {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(() => {
return children.length * spread;
}, [children, spread]);
return (
<MotionComponent
className={cn(
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text',
'text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]',
'[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]',
'dark:[--base-color:#71717a] dark:[--base-gradient-color:#ffffff] dark:[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]',
className
)}
initial={{ backgroundPosition: '100% center' }}
animate={{ backgroundPosition: '0% center' }}
transition={{
repeat: Infinity,
duration,
ease: 'linear',
}}
style={
{
'--spread': `${dynamicSpread}px`,
backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
} as React.CSSProperties
}
>
{children}
</MotionComponent>
);
}

View File

@ -5,3 +5,40 @@ export type PaymentType = {
amount: number;
paid: boolean;
};
interface IpAddress {
ip: string;
mask: number;
}
interface Ipv6Address {
ip: string;
prefix: number;
}
export interface MacAddress {
ruleId?: number;
name: string;
macAddress: string;
}
export interface GroupProfile {
groupId: string;
site?: string;
name: string;
buildIn?: boolean;
ipList?: IpAddress[];
ipv6List?: Ipv6Address[];
macAddressList?: MacAddress[];
count: number;
type: number;
resource: number;
}
export interface OmadaResponse {
errorCode: number;
msg: string;
result: {
data: GroupProfile[];
};
}

View File

@ -26,3 +26,23 @@ export const formatMacAddress = (mac: string): string => {
// Provide a fallback if formatted is null
return formatted ? formatted.join("-") : "";
};
export function validateApiKey(request: Request) {
// Get API key from environment variable
const validApiKey = process.env.CRON_API_KEY;
if (!validApiKey) {
throw new Error("CRON_API_KEY is not configured");
}
// Get API key from request header
const apiKey = request.headers.get("x-api-key");
if (!apiKey) {
throw new Error("API key is missing");
}
if (apiKey !== validApiKey) {
throw new Error("Invalid API key");
}
}

62
package-lock.json generated
View File

@ -31,6 +31,7 @@
"date-fns": "^4.1.0",
"jotai": "^2.8.0",
"lucide-react": "^0.460.0",
"motion": "^11.15.0",
"next": "15.0.3",
"next-themes": "^0.4.3",
"nextjs-toploader": "^3.7.15",
@ -6486,6 +6487,32 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/framer-motion": {
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.15.0.tgz",
"integrity": "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==",
"dependencies": {
"motion-dom": "^11.14.3",
"motion-utils": "^11.14.3",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-monkey": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz",
@ -7604,6 +7631,41 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion": {
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-11.15.0.tgz",
"integrity": "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==",
"dependencies": {
"framer-motion": "^11.15.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "11.14.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz",
"integrity": "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA=="
},
"node_modules/motion-utils": {
"version": "11.14.3",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz",
"integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -35,6 +35,7 @@
"date-fns": "^4.1.0",
"jotai": "^2.8.0",
"lucide-react": "^0.460.0",
"motion": "^11.15.0",
"next": "15.0.3",
"next-themes": "^0.4.3",
"nextjs-toploader": "^3.7.15",

View File

@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `firstPaymentDone` on the `user` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Device" ADD COLUMN "registered" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "user" DROP COLUMN "firstPaymentDone";

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Device" ADD COLUMN "blocked" BOOLEAN NOT NULL DEFAULT false;

View File

@ -14,19 +14,19 @@ datasource db {
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified Boolean @default(false)
firstPaymentDone Boolean @default(false)
verified Boolean @default(false)
accNo String?
id String @id @default(cuid())
name String?
email String? @unique
emailVerified Boolean @default(false)
verified Boolean @default(false)
accNo String?
// island String?
address String?
id_card String? @unique
dob DateTime?
atoll Atoll? @relation(fields: [atollId], references: [id])
island Island? @relation(fields: [islandId], references: [id])
address String?
id_card String? @unique
dob DateTime?
atoll Atoll? @relation(fields: [atollId], references: [id])
island Island? @relation(fields: [islandId], references: [id])
image String?
createdAt DateTime @default(now())
@ -114,6 +114,8 @@ model Device {
name String
mac String
isActive Boolean @default(false)
registered Boolean @default(false)
blocked Boolean @default(false)
expiryDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -1,4 +1,3 @@
import { faker } from "@faker-js/faker";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
@ -12,12 +11,11 @@ async function main() {
},
update: {},
create: {
name: "Admin",
email: "admin@sarlink.net",
name: "Admin Admin",
email: "admin@example.com",
emailVerified: true,
firstPaymentDone: true,
verified: true,
address: "Dharanboodhoo",
address: "Sky villa",
id_card: "A265117",
dob: new Date("1990-01-01"),
phoneNumber: "+9607780588",
@ -25,38 +23,7 @@ async function main() {
role: "ADMIN",
},
});
const users = Array.from({ length: 25 }, () => ({
name: `${faker.person.fullName().split(" ")[1]} House-${crypto
.randomUUID()
.slice(0, 5)}`,
email: faker.internet.email(),
emailVerified: false,
firstPaymentDone: false,
verified: false,
address: faker.location.streetAddress(),
id_card: `A${Math.round(Math.random() * 999999)}`,
dob: faker.date.between({
from: "1900-01-01",
to: "2000-01-01",
}),
phoneNumber: String(faker.number.int({ min: 7000000, max: 9999999 })),
phoneNumberVerified: false,
role: "USER",
}));
const seedUsers = await Promise.all(
users.map((user) => prisma.user.create({ data: user })),
);
const FAKE_DEVICES = Array.from({ length: 25 }, () => ({
name: faker.commerce.productName(),
mac: faker.internet.mac(),
userId: seedUsers[Math.floor(Math.random() * seedUsers.length)].id,
}));
await prisma.device.createMany({
data: FAKE_DEVICES,
});
const FAAFU_ATOLL = await prisma.atoll.create({
data: {
name: "F",