diff --git a/app/api/auth/login/route.tsx b/app/api/auth/login/route.tsx index ffbe0d2..de83096 100644 --- a/app/api/auth/login/route.tsx +++ b/app/api/auth/login/route.tsx @@ -16,6 +16,8 @@ export async function POST(request: Request) { body: JSON.stringify({ email, password }), }); + console.log("[LOGIN] resp", resp); + if (!resp.ok) { const errJson = await safeJson(resp); return NextResponse.json( diff --git a/app/api/dashboard/admin/groups/route.ts b/app/api/dashboard/admin/groups/route.ts new file mode 100644 index 0000000..a7c6daa --- /dev/null +++ b/app/api/dashboard/admin/groups/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildFilterParam } from "../utils"; + +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; + +export async function POST(request: NextRequest) { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const body = await request.json(); + const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + + const queryParams = new URLSearchParams(); + queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("page", String(pagination.page ?? 1)); + + if (sort?.field && sort?.order) { + queryParams.set("sort", `${sort.field}:${sort.order}`); + } + + const filterParam = buildFilterParam(filters); + if (filterParam) { + queryParams.set("filter", filterParam); + } + + const backendUrl = `${BE_BASE_URL}/api/v1/groups${ + queryParams.size ? `?${queryParams.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch groups" })); + + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch groups", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error("Proxy POST /api/dashboard/admin/groups error:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/admin/permissions/route.ts b/app/api/dashboard/admin/permissions/route.ts new file mode 100644 index 0000000..8fb217d --- /dev/null +++ b/app/api/dashboard/admin/permissions/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildFilterParam } from "../utils"; + +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; + +export async function POST(request: NextRequest) { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const body = await request.json(); + const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + + const queryParams = new URLSearchParams(); + queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("page", String(pagination.page ?? 1)); + + if (sort?.field && sort?.order) { + queryParams.set("sort", `${sort.field}:${sort.order}`); + } + + const filterParam = buildFilterParam(filters); + if (filterParam) { + queryParams.set("filter", filterParam); + } + + const backendUrl = `${BE_BASE_URL}/api/v1/permissions${ + queryParams.size ? `?${queryParams.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch permissions" })); + + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch permissions", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error("Proxy POST /api/dashboard/admin/permissions error:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/admin/sessions/route.ts b/app/api/dashboard/admin/sessions/route.ts new file mode 100644 index 0000000..17049c6 --- /dev/null +++ b/app/api/dashboard/admin/sessions/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildFilterParam } from "../utils"; + +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; + +export async function POST(request: NextRequest) { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const body = await request.json(); + const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + + const queryParams = new URLSearchParams(); + queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("page", String(pagination.page ?? 1)); + + if (sort?.field && sort?.order) { + queryParams.set("sort", `${sort.field}:${sort.order}`); + } + + const filterParam = buildFilterParam(filters); + if (filterParam) { + queryParams.set("filter", filterParam); + } + + const backendUrl = `${BE_BASE_URL}/api/v1/sessions${ + queryParams.size ? `?${queryParams.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch sessions" })); + + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch sessions", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error("Proxy POST /api/dashboard/admin/sessions error:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/admin/utils.ts b/app/api/dashboard/admin/utils.ts new file mode 100644 index 0000000..ca70038 --- /dev/null +++ b/app/api/dashboard/admin/utils.ts @@ -0,0 +1,34 @@ +export type FilterValue = + | string + | { + operator?: string; + value: string; + }; + +export const buildFilterParam = (filters: Record) => { + const filterExpressions: string[] = []; + + for (const [key, filterValue] of Object.entries(filters)) { + if (!filterValue) continue; + + let operator = "=="; + let value: string; + + if (typeof filterValue === "string") { + value = filterValue; + } else { + operator = filterValue.operator || "=="; + value = filterValue.value; + } + + if (!value) continue; + + const encodedValue = encodeURIComponent(value); + const needsEqualsPrefix = /^[A-Za-z]/.test(operator); + const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator; + + filterExpressions.push(`${key}${operatorSegment}/${encodedValue}`); + } + + return filterExpressions.length > 0 ? filterExpressions.join(",") : undefined; +}; diff --git a/app/api/dashboard/transactions/withdrawals/route.ts b/app/api/dashboard/transactions/withdrawals/route.ts new file mode 100644 index 0000000..eafbad8 --- /dev/null +++ b/app/api/dashboard/transactions/withdrawals/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; + +type FilterValue = + | string + | { + operator?: string; + value: string; + }; + +export async function POST(request: NextRequest) { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const body = await request.json(); + const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + + // Force withdrawals filter while allowing other filters to stack + const mergedFilters: Record = { + ...filters, + Type: { + operator: "==", + value: "withdrawal", + }, + }; + + const queryParts: string[] = []; + queryParts.push(`limit=${pagination.limit}`); + queryParts.push(`page=${pagination.page}`); + + if (sort) { + queryParts.push(`sort=${sort.field}:${sort.order}`); + } + + for (const [key, filterValue] of Object.entries(mergedFilters)) { + if (!filterValue) continue; + + let operator: string; + let value: string; + + if (typeof filterValue === "string") { + operator = "=="; + value = filterValue; + } else { + operator = filterValue.operator || "=="; + value = filterValue.value; + } + + if (!value) continue; + + const encodedValue = encodeURIComponent(value); + const needsEqualsPrefix = /^[A-Za-z]/.test(operator); + const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator; + + queryParts.push(`${key}${operatorSegment}/${encodedValue}`); + } + + const queryString = queryParts.join("&"); + const backendUrl = `${BE_BASE_URL}/api/v1/transactions${ + queryString ? `?${queryString}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch withdrawals" })); + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch withdrawals", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error( + "Proxy POST /api/dashboard/transactions/withdrawals error:", + err + ); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/admin/groups/page.tsx b/app/dashboard/admin/groups/page.tsx new file mode 100644 index 0000000..f2e668d --- /dev/null +++ b/app/dashboard/admin/groups/page.tsx @@ -0,0 +1,13 @@ +import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; + +export default function GroupsPage() { + return ( + + ); +} diff --git a/app/dashboard/admin/permissions/page.tsx b/app/dashboard/admin/permissions/page.tsx new file mode 100644 index 0000000..cde9367 --- /dev/null +++ b/app/dashboard/admin/permissions/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; + +export default function PermissionsPage() { + return ( + + ); +} diff --git a/app/dashboard/admin/sessions/page.tsx b/app/dashboard/admin/sessions/page.tsx new file mode 100644 index 0000000..8d750b6 --- /dev/null +++ b/app/dashboard/admin/sessions/page.tsx @@ -0,0 +1,13 @@ +import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; + +export default function SessionsPage() { + return ( + + ); +} diff --git a/app/dashboard/audits/page.scss b/app/dashboard/audits/page.scss index e8f142e..4951157 100644 --- a/app/dashboard/audits/page.scss +++ b/app/dashboard/audits/page.scss @@ -30,7 +30,7 @@ overflow: hidden; .scroll-wrapper { - width: 100dvw; + width: 85dvw; overflow-x: auto; overflow-y: hidden; diff --git a/app/dashboard/transactions/withdrawals/page.tsx b/app/dashboard/transactions/withdrawals/page.tsx index e61e9bd..b676a9e 100644 --- a/app/dashboard/transactions/withdrawals/page.tsx +++ b/app/dashboard/transactions/withdrawals/page.tsx @@ -1,23 +1,100 @@ +"use client"; + import DataTable from "@/app/features/DataTable/DataTable"; -import { getTransactions } from "@/app/services/transactions"; +import { useDispatch, useSelector } from "react-redux"; +import { AppDispatch } from "@/app/redux/store"; +import { + selectFilters, + selectPagination, + selectSort, +} from "@/app/redux/advanedSearch/selectors"; +import { + setStatus, + setError as setAdvancedSearchError, +} from "@/app/redux/advanedSearch/advancedSearchSlice"; +import { useEffect, useMemo, useState } from "react"; +import { TransactionRow, BackendTransaction } from "../interface"; -export default async function WithdrawalTransactionPage({ - searchParams, -}: { - searchParams: Promise>; -}) { - // Await searchParams before processing - const params = await searchParams; - // Create a safe query string by filtering only string values - const safeParams: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (typeof value === "string") { - safeParams[key] = value; - } - } - const query = new URLSearchParams(safeParams).toString(); - const transactionType = "withdrawal"; - const data = await getTransactions({ transactionType, query }); +export default function WithdrawalTransactionPage() { + const dispatch = useDispatch(); + const filters = useSelector(selectFilters); + const pagination = useSelector(selectPagination); + const sort = useSelector(selectSort); + const [tableRows, setTableRows] = useState([]); + const [rowCount, setRowCount] = useState(0); - return ; + const memoizedRows = useMemo(() => tableRows, [tableRows]); + + const withdrawalFilters = useMemo(() => { + return { + ...filters, + Type: { + operator: "==", + value: "withdrawal", + }, + }; + }, [filters]); + + useEffect(() => { + const fetchWithdrawals = async () => { + dispatch(setStatus("loading")); + dispatch(setAdvancedSearchError(null)); + try { + const response = await fetch( + "/api/dashboard/transactions/withdrawals", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filters: withdrawalFilters, + pagination, + sort, + }), + } + ); + + if (!response.ok) { + dispatch(setAdvancedSearchError("Failed to fetch withdrawals")); + setTableRows([]); + return; + } + + const backendData = await response.json(); + const transactions: BackendTransaction[] = + backendData.transactions || []; + + const rows: TransactionRow[] = transactions.map(tx => ({ + id: tx.id, + userId: tx.customer, + transactionId: String(tx.external_id ?? tx.id), + type: tx.type, + currency: tx.currency, + amount: tx.amount, + status: tx.status, + dateTime: tx.created || tx.modified, + merchantId: tx.merchant_id, + pspId: tx.psp_id, + methodId: tx.method_id, + modified: tx.modified, + })); + + setTableRows(rows); + setRowCount(100); + dispatch(setStatus("succeeded")); + } catch (error) { + dispatch( + setAdvancedSearchError( + error instanceof Error ? error.message : "Unknown error" + ) + ); + setTableRows([]); + } + }; + + fetchWithdrawals(); + }, [dispatch, withdrawalFilters, pagination, sort]); + + return ( + + ); } diff --git a/app/features/AdminList/AdminResourceList.tsx b/app/features/AdminList/AdminResourceList.tsx new file mode 100644 index 0000000..4695d26 --- /dev/null +++ b/app/features/AdminList/AdminResourceList.tsx @@ -0,0 +1,290 @@ +"use client"; + +import Spinner from "@/app/components/Spinner/Spinner"; +import { DataRowBase } from "@/app/features/DataTable/types"; +import { + setError as setAdvancedSearchError, + setStatus, +} from "@/app/redux/advanedSearch/advancedSearchSlice"; +import { + selectError, + selectFilters, + selectPagination, + selectSort, + selectStatus, +} from "@/app/redux/advanedSearch/selectors"; +import { AppDispatch } from "@/app/redux/store"; +import { + Alert, + Box, + Chip, + Divider, + List, + ListItem, + Typography, +} from "@mui/material"; +import { useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +type ResourceRow = DataRowBase & Record; + +type FilterValue = + | string + | { + operator?: string; + value: string; + }; + +interface AdminResourceListProps { + title: string; + endpoint: string; + responseCollectionKeys?: string[]; + primaryLabelKeys: string[]; + chipKeys?: string[]; + excludeKeys?: string[]; + filterOverrides?: Record; +} + +const DEFAULT_COLLECTION_KEYS = ["data", "items"]; + +const ensureRowId = ( + row: Record, + fallbackId: number +): ResourceRow => { + const currentId = row.id; + + if (typeof currentId === "number") { + return row as ResourceRow; + } + + const numericId = Number(currentId); + + if (!Number.isNaN(numericId) && numericId !== 0) { + return { ...row, id: numericId } as ResourceRow; + } + + return { ...row, id: fallbackId } as ResourceRow; +}; + +const resolveCollection = ( + payload: Record, + preferredKeys: string[] = [] +) => { + for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) { + const maybeCollection = payload?.[key]; + if (Array.isArray(maybeCollection)) { + return maybeCollection as Record[]; + } + } + + if (Array.isArray(payload)) { + return payload as Record[]; + } + + return []; +}; + +const AdminResourceList = ({ + title, + endpoint, + responseCollectionKeys = [], + primaryLabelKeys, + chipKeys = [], + excludeKeys = [], +}: AdminResourceListProps) => { + const dispatch = useDispatch(); + const filters = useSelector(selectFilters); + const pagination = useSelector(selectPagination); + const sort = useSelector(selectSort); + const status = useSelector(selectStatus); + const errorMessage = useSelector(selectError); + + const [rows, setRows] = useState([]); + + const normalizedTitle = title.toLowerCase(); + + const excludedKeys = useMemo(() => { + const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]); + excludeKeys.forEach(key => baseExcluded.add(key)); + return Array.from(baseExcluded); + }, [primaryLabelKeys, chipKeys, excludeKeys]); + + const getPrimaryLabel = (row: ResourceRow) => { + for (const key of primaryLabelKeys) { + if (row[key]) { + return String(row[key]); + } + } + return `${title} #${row.id}`; + }; + + const getMetaChips = (row: ResourceRow) => + chipKeys + .filter(key => row[key]) + .map(key => ({ + key, + value: String(row[key]), + })); + + const getSecondaryDetails = (row: ResourceRow) => + Object.entries(row).filter(([key]) => !excludedKeys.includes(key)); + + const resolvedCollectionKeys = useMemo( + () => [...responseCollectionKeys], + [responseCollectionKeys] + ); + + useEffect(() => { + const fetchResources = async () => { + dispatch(setStatus("loading")); + dispatch(setAdvancedSearchError(null)); + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filters, + pagination, + sort, + }), + }); + + if (!response.ok) { + dispatch( + setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`) + ); + setRows([]); + return; + } + + const backendData = await response.json(); + const collection = resolveCollection( + backendData, + resolvedCollectionKeys + ); + + const nextRows = collection.map((item, index) => + ensureRowId(item, index + 1) + ); + + setRows(nextRows); + dispatch(setStatus("succeeded")); + } catch (error) { + dispatch( + setAdvancedSearchError( + error instanceof Error ? error.message : "Unknown error" + ) + ); + setRows([]); + } + }; + + fetchResources(); + }, [ + dispatch, + endpoint, + filters, + pagination, + sort, + resolvedCollectionKeys, + normalizedTitle, + ]); + + return ( + + + {title} + + + {status === "loading" && ( + + + + {`Loading ${normalizedTitle}...`} + + + )} + + {status === "failed" && ( + + {errorMessage || `Failed to load ${normalizedTitle}`} + + )} + + {!rows.length && status === "succeeded" && ( + + {`No ${normalizedTitle} found.`} + + )} + + {rows.length > 0 && ( + + {rows.map(row => { + const chips = getMetaChips(row); + const secondary = getSecondaryDetails(row); + + return ( + + + + + + {getPrimaryLabel(row)} + + + + ID: {row.id} + + + + {chips.length > 0 && ( + + {chips.map(chip => ( + + ))} + + )} + + {secondary.length > 0 && ( + + {secondary + .map(([key, value]) => `${key}: ${String(value)}`) + .join(" โ€ข ")} + + )} + + + + + ); + })} + + )} + + ); +}; + +export default AdminResourceList; diff --git a/app/features/DataTable/DataTable.tsx b/app/features/DataTable/DataTable.tsx index d39fcae..86a8b75 100644 --- a/app/features/DataTable/DataTable.tsx +++ b/app/features/DataTable/DataTable.tsx @@ -140,7 +140,7 @@ const DataTable = ({ onOpenExport={() => {}} /> - + { const user = useSelector((state: RootState) => state.auth.user); + console.log("[SettingsPersonalInfo] user", user); const dispatch = useDispatch(); const [formData, setFormData] = useState({ @@ -28,8 +29,8 @@ const SettingsPersonalInfo: React.FC = () => { useEffect(() => { if (user) { setFormData({ - first_name: user.firstName ?? "", - last_name: user.lastName ?? "", + first_name: user.first_name ?? "", + last_name: user.last_name ?? "", username: user.username ?? "", email: user.email ?? "", }); @@ -66,17 +67,20 @@ const SettingsPersonalInfo: React.FC = () => { = ({ open, onClose }) => { const loading = status === "loading"; + const COUNTRY_CODES = useSelector(selectPhoneNumberCountries); + const handleChange = ( e: React.ChangeEvent ) => { @@ -115,7 +119,6 @@ const AddUser: React.FC = ({ open, onClose }) => { if (result && resultAction.payload.success) { toast.success(resultAction.payload.message); - // router.refresh(); onClose(); } } catch (err) { @@ -255,9 +258,12 @@ const AddUser: React.FC = ({ open, onClose }) => { onChange={handleCountryCodeChange} className="country-code-select" > - {COUNTRY_CODES.map(country => ( - ))} diff --git a/app/features/dashboard/header/Header.tsx b/app/features/dashboard/header/Header.tsx index d1d4d61..542e4d4 100644 --- a/app/features/dashboard/header/Header.tsx +++ b/app/features/dashboard/header/Header.tsx @@ -5,8 +5,6 @@ import AccountMenu from "./accountMenu/AccountMenu"; import "./Header.scss"; const Header = () => { - const handleChange = () => {}; - return ( { >
- +
diff --git a/app/features/dashboard/header/dropDown/DropDown.tsx b/app/features/dashboard/header/dropDown/DropDown.tsx index f81b600..f7997cd 100644 --- a/app/features/dashboard/header/dropDown/DropDown.tsx +++ b/app/features/dashboard/header/dropDown/DropDown.tsx @@ -1,57 +1,137 @@ import React from "react"; import { - FormControl, - InputLabel, - Select, + Button, + Menu, MenuItem, - SelectChangeEvent, + ListItemText, + ListItemIcon, + Divider, } from "@mui/material"; -import PageLinks from "../../../../components/PageLinks/PageLinks"; import { useSelector } from "react-redux"; +import { useRouter } from "next/navigation"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { selectNavigationSidebar } from "@/app/redux/metadata/selectors"; +import { SidebarLink } from "@/app/redux/metadata/metadataSlice"; +import { resolveIcon } from "@/app/utils/iconMap"; import "./DropDown.scss"; interface Props { - onChange?: (event: SelectChangeEvent) => void; + onChange?: (path: string) => void; } export default function SidebarDropdown({ onChange }: Props) { - const [value, setValue] = React.useState(""); - const sidebar = useSelector(selectNavigationSidebar)?.links; - const handleChange = (event: SelectChangeEvent) => { - setValue(event.target.value); - onChange?.(event); + const [anchorEl, setAnchorEl] = React.useState(null); + const [openMenus, setOpenMenus] = React.useState>({}); + const sidebar = useSelector(selectNavigationSidebar); + const router = useRouter(); + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + setOpenMenus({}); + }; + + const toggleMenu = (title: string) => { + setOpenMenus(prev => ({ ...prev, [title]: !prev[title] })); + }; + + const handleNavigation = (path: string) => { + router.push(path); + onChange?.(path); + handleClose(); + }; + + const renderMenuItem = ( + link: SidebarLink, + level: number = 0 + ): React.ReactNode => { + const Icon = link.icon ? resolveIcon(link.icon as string) : undefined; + const hasChildren = link.children && link.children.length > 0; + const isOpen = openMenus[link.title]; + const indent = level * 24; + + if (hasChildren) { + return ( + + toggleMenu(link.title)} + sx={{ + pl: `${8 + indent}px`, + }} + > + {Icon && ( + + + + )} + + {isOpen ? ( + + ) : ( + + )} + + {isOpen && + link.children?.map(child => renderMenuItem(child, level + 1))} + + ); + } + + return ( + handleNavigation(link.path)} + sx={{ + pl: `${8 + indent}px`, + }} + > + {Icon && ( + + + + )} + + + ); }; return ( - - Navigate To - - + +
); } diff --git a/app/features/dashboard/layout/mainContent.tsx b/app/features/dashboard/layout/mainContent.tsx index 1f55fd3..2b6cd32 100644 --- a/app/features/dashboard/layout/mainContent.tsx +++ b/app/features/dashboard/layout/mainContent.tsx @@ -10,7 +10,7 @@ export const MainContent = ({ children }: MainContentProps) => { const isSidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen); const style: React.CSSProperties = { - marginLeft: isSidebarOpen ? "240px" : "30px", + marginLeft: isSidebarOpen ? "230px" : "30px", padding: "24px", minHeight: "100vh", width: isSidebarOpen ? "calc(100% - 240px)" : "calc(100% - 30px)", diff --git a/app/redux/metadata/constants.ts b/app/redux/metadata/constants.ts new file mode 100644 index 0000000..e7712d9 --- /dev/null +++ b/app/redux/metadata/constants.ts @@ -0,0 +1,240 @@ +// Country codes for phone prefixes +export const COUNTRY_CODES = [ + { code: "+233", flag: "๐Ÿ‡ฌ๐Ÿ‡ญ", country: "Ghana" }, + { code: "+672", flag: "๐Ÿ‡ณ๐Ÿ‡ซ", country: "Norfolk Island" }, + { code: "+226", flag: "๐Ÿ‡ง๐Ÿ‡ซ", country: "Burkina Faso" }, + { code: "+591", flag: "๐Ÿ‡ง๐Ÿ‡ด", country: "Bolivia" }, + { code: "+33", flag: "๐Ÿ‡ซ๐Ÿ‡ท", country: "France" }, + { code: "+1-876", flag: "๐Ÿ‡ฏ๐Ÿ‡ฒ", country: "Jamaica" }, + { code: "+249", flag: "๐Ÿ‡ธ๐Ÿ‡ฉ", country: "Sudan" }, + { + code: "+243", + flag: "๐Ÿ‡จ๐Ÿ‡ฉ", + country: "Congo, Democratic Republic Of (Was Zaire)", + }, + { code: "+679", flag: "๐Ÿ‡ซ๐Ÿ‡ฏ", country: "Fiji" }, + { code: "+44", flag: "๐Ÿ‡ฎ๐Ÿ‡ฒ", country: "Isle Of Man" }, + { code: "+382", flag: "๐Ÿ‡ฒ๐Ÿ‡ช", country: "Montenegro" }, + { code: "+353", flag: "๐Ÿ‡ฎ๐Ÿ‡ช", country: "Ireland" }, + { code: "+237", flag: "๐Ÿ‡จ๐Ÿ‡ฒ", country: "Cameroon" }, + { code: "+592", flag: "๐Ÿ‡ฌ๐Ÿ‡พ", country: "Guyana" }, + { code: "+234", flag: "๐Ÿ‡ณ๐Ÿ‡ฌ", country: "Nigeria" }, + { code: "+1-868", flag: "๐Ÿ‡น๐Ÿ‡น", country: "Trinidad And Tobago" }, + { code: "+45", flag: "๐Ÿ‡ฉ๐Ÿ‡ฐ", country: "Denmark" }, + { code: "+852", flag: "๐Ÿ‡ญ๐Ÿ‡ฐ", country: "Hong Kong" }, + { code: "+223", flag: "๐Ÿ‡ฒ๐Ÿ‡ฑ", country: "Mali" }, + { code: "+239", flag: "๐Ÿ‡ธ๐Ÿ‡น", country: "Sao Tome And Principe" }, + { code: "+690", flag: "๐Ÿ‡น๐Ÿ‡ฐ", country: "Tokelau" }, + { code: "+590", flag: "๐Ÿ‡ฌ๐Ÿ‡ต", country: "Guadeloupe" }, + { code: "+66", flag: "๐Ÿ‡น๐Ÿ‡ญ", country: "Thailand" }, + { code: "+504", flag: "๐Ÿ‡ญ๐Ÿ‡ณ", country: "Honduras" }, + { code: "+27", flag: "๐Ÿ‡ฟ๐Ÿ‡ฆ", country: "South Africa" }, + { code: "+358", flag: "๐Ÿ‡ซ๐Ÿ‡ฎ", country: "Finland" }, + { code: "+1-264", flag: "๐Ÿ‡ฆ๐Ÿ‡ฎ", country: "Anguilla" }, + { code: "+262", flag: "๐Ÿ‡ท๐Ÿ‡ช", country: "Reunion" }, + { code: "+992", flag: "๐Ÿ‡น๐Ÿ‡ฏ", country: "Tajikistan" }, + { code: "+971", flag: "๐Ÿ‡ฆ๐Ÿ‡ช", country: "United Arab Emirates" }, + { code: "+212", flag: "๐Ÿ‡ช๐Ÿ‡ญ", country: "Western Sahara" }, + { code: "+692", flag: "๐Ÿ‡ฒ๐Ÿ‡ญ", country: "Marshall Islands" }, + { code: "+674", flag: "๐Ÿ‡ณ๐Ÿ‡ท", country: "Nauru" }, + { code: "+229", flag: "๐Ÿ‡ง๐Ÿ‡ฏ", country: "Benin" }, + { code: "+55", flag: "๐Ÿ‡ง๐Ÿ‡ท", country: "Brazil" }, + { code: "+299", flag: "๐Ÿ‡ฌ๐Ÿ‡ฑ", country: "Greenland" }, + { code: "+61", flag: "๐Ÿ‡ญ๐Ÿ‡ฒ", country: "Heard and Mc Donald Islands" }, + { code: "+98", flag: "๐Ÿ‡ฎ๐Ÿ‡ท", country: "Iran (Islamic Republic Of)" }, + { code: "+231", flag: "๐Ÿ‡ฑ๐Ÿ‡ท", country: "Liberia" }, + { code: "+370", flag: "๐Ÿ‡ฑ๐Ÿ‡น", country: "Lithuania" }, + { code: "+377", flag: "๐Ÿ‡ฒ๐Ÿ‡จ", country: "Monaco" }, + { code: "+222", flag: "๐Ÿ‡ฒ๐Ÿ‡ท", country: "Mauritania" }, + { code: "+57", flag: "๐Ÿ‡จ๐Ÿ‡ด", country: "Colombia" }, + { code: "+216", flag: "๐Ÿ‡น๐Ÿ‡ณ", country: "Tunisia" }, + { code: "+1-345", flag: "๐Ÿ‡ฐ๐Ÿ‡พ", country: "Cayman Islands" }, + { code: "+62", flag: "๐Ÿ‡ฎ๐Ÿ‡ฉ", country: "Indonesia" }, + { code: "+378", flag: "๐Ÿ‡ธ๐Ÿ‡ฒ", country: "San Marino" }, + { code: "+1", flag: "๐Ÿ‡บ๐Ÿ‡ธ", country: "United States" }, + { code: "+383", flag: "๐Ÿ‡ฝ๐Ÿ‡ฐ", country: "Kosovo" }, + { code: "+376", flag: "๐Ÿ‡ฆ๐Ÿ‡ฉ", country: "Andorra" }, + { code: "+1-246", flag: "๐Ÿ‡ง๐Ÿ‡ง", country: "Barbados" }, + { code: "+963", flag: "๐Ÿ‡ธ๐Ÿ‡พ", country: "Syrian Arab Republic" }, + { code: "+359", flag: "๐Ÿ‡ง๐Ÿ‡ฌ", country: "Bulgaria" }, + { code: "+213", flag: "๐Ÿ‡ฉ๐Ÿ‡ฟ", country: "Algeria" }, + { code: "+593", flag: "๐Ÿ‡ช๐Ÿ‡จ", country: "Ecuador" }, + { code: "+240", flag: "๐Ÿ‡ฌ๐Ÿ‡ถ", country: "Equatorial Guinea" }, + { code: "+44", flag: "๐Ÿ‡ฏ๐Ÿ‡ช", country: "Jersey" }, + { code: "+254", flag: "๐Ÿ‡ฐ๐Ÿ‡ช", country: "Kenya" }, + { code: "+64", flag: "๐Ÿ‡ณ๐Ÿ‡ฟ", country: "New Zealand" }, + { code: "+250", flag: "๐Ÿ‡ท๐Ÿ‡ผ", country: "Rwanda" }, + { code: "+291", flag: "๐Ÿ‡ช๐Ÿ‡ท", country: "Eritrea" }, + { code: "+47", flag: "๐Ÿ‡ณ๐Ÿ‡ด", country: "Norway" }, + { code: "+51", flag: "๐Ÿ‡ต๐Ÿ‡ช", country: "Peru" }, + { code: "+290", flag: "๐Ÿ‡ธ๐Ÿ‡ญ", country: "Saint Helena" }, + { code: "+508", flag: "๐Ÿ‡ต๐Ÿ‡ฒ", country: "Saint Pierre And Miquelon" }, + { code: "+260", flag: "๐Ÿ‡ฟ๐Ÿ‡ฒ", country: "Zambia" }, + { code: "+354", flag: "๐Ÿ‡ฎ๐Ÿ‡ธ", country: "Iceland" }, + { code: "+39", flag: "๐Ÿ‡ฎ๐Ÿ‡น", country: "Italy" }, + { code: "+977", flag: "๐Ÿ‡ณ๐Ÿ‡ต", country: "Nepal" }, + { code: "+386", flag: "๐Ÿ‡ธ๐Ÿ‡ฎ", country: "Slovenia" }, + { code: "+218", flag: "๐Ÿ‡ฑ๐Ÿ‡พ", country: "Libyan Arab Jamahiriya" }, + { code: "+505", flag: "๐Ÿ‡ณ๐Ÿ‡ฎ", country: "Nicaragua" }, + { code: "+248", flag: "๐Ÿ‡ธ๐Ÿ‡จ", country: "Seychelles" }, + { code: "+594", flag: "๐Ÿ‡ฌ๐Ÿ‡ซ", country: "French Guiana" }, + { code: "+972", flag: "๐Ÿ‡ฎ๐Ÿ‡ฑ", country: "Israel" }, + { code: "+1-670", flag: "๐Ÿ‡ฒ๐Ÿ‡ต", country: "Northern Mariana Islands" }, + { code: "+1-64", flag: "๐Ÿ‡ต๐Ÿ‡ณ", country: "Pitcairn" }, + { code: "+351", flag: "๐Ÿ‡ต๐Ÿ‡น", country: "Portugal" }, + { code: "+503", flag: "๐Ÿ‡ธ๐Ÿ‡ป", country: "El Salvador" }, + { code: "+44", flag: "๐Ÿ‡ฌ๐Ÿ‡ง", country: "United Kingdom" }, + { code: "+689", flag: "๐Ÿ‡ต๐Ÿ‡ซ", country: "French Polynesia" }, + { code: "+1-721", flag: "๐Ÿ‡ธ๐Ÿ‡ฝ", country: "Sint Maarten" }, + { code: "+380", flag: "๐Ÿ‡บ๐Ÿ‡ฆ", country: "Ukraine" }, + { code: "+599", flag: "๐Ÿ‡ง๐Ÿ‡ถ", country: "Bonaire, Saint Eustatius and Saba" }, + { code: "+500", flag: "๐Ÿ‡ซ๐Ÿ‡ฐ", country: "Falkland Islands (Malvinas)" }, + { code: "+995", flag: "๐Ÿ‡ฌ๐Ÿ‡ช", country: "Georgia" }, + { code: "+1-671", flag: "๐Ÿ‡ฌ๐Ÿ‡บ", country: "Guam" }, + { code: "+82", flag: "๐Ÿ‡ฐ๐Ÿ‡ท", country: "Korea, Republic Of" }, + { code: "+507", flag: "๐Ÿ‡ต๐Ÿ‡ฆ", country: "Panama" }, + { code: "+1", flag: "๐Ÿ‡บ๐Ÿ‡ธ", country: "United States Minor Outlying Islands" }, + { code: "+964", flag: "๐Ÿ‡ฎ๐Ÿ‡ถ", country: "Iraq" }, + { code: "+965", flag: "๐Ÿ‡ฐ๐Ÿ‡ผ", country: "Kuwait" }, + { code: "+39", flag: "๐Ÿ‡ป๐Ÿ‡ฆ", country: "Vatican City State (Holy See)" }, + { code: "+385", flag: "๐Ÿ‡ญ๐Ÿ‡ท", country: "Croatia (Local Name: Hrvatska)" }, + { code: "+92", flag: "๐Ÿ‡ต๐Ÿ‡ฐ", country: "Pakistan" }, + { code: "+967", flag: "๐Ÿ‡พ๐Ÿ‡ช", country: "Yemen" }, + { code: "+267", flag: "๐Ÿ‡ง๐Ÿ‡ผ", country: "Botswana" }, + { code: "+970", flag: "๐Ÿ‡ต๐Ÿ‡ธ", country: "Palestinian Territory, Occupied" }, + { code: "+90", flag: "๐Ÿ‡น๐Ÿ‡ท", country: "Turkey" }, + { code: "+1-473", flag: "๐Ÿ‡ฌ๐Ÿ‡ฉ", country: "Grenada" }, + { code: "+356", flag: "๐Ÿ‡ฒ๐Ÿ‡น", country: "Malta" }, + { + code: "+995", + flag: "๐Ÿ‡ฌ๐Ÿ‡ช", + country: "South Georgia And The South Sandwich Islands", + }, + { code: "+236", flag: "๐Ÿ‡จ๐Ÿ‡ซ", country: "Central African Republic" }, + { code: "+371", flag: "๐Ÿ‡ฑ๐Ÿ‡ป", country: "Latvia" }, + { + code: "+850", + flag: "๐Ÿ‡ฐ๐Ÿ‡ต", + country: "Korea, Democratic People's Republic Of", + }, + { code: "+1-649", flag: "๐Ÿ‡น๐Ÿ‡จ", country: "Turks And Caicos Islands" }, + { code: "+599", flag: "๐Ÿ‡จ๐Ÿ‡ผ", country: "Curacao" }, + { code: "+245", flag: "๐Ÿ‡ฌ๐Ÿ‡ผ", country: "Guinea-Bissau" }, + { code: "+94", flag: "๐Ÿ‡ฑ๐Ÿ‡ฐ", country: "Sri Lanka" }, + { code: "+596", flag: "๐Ÿ‡ฒ๐Ÿ‡ถ", country: "Martinique" }, + { code: "+262", flag: "๐Ÿ‡พ๐Ÿ‡น", country: "Mayotte" }, + { code: "+688", flag: "๐Ÿ‡น๐Ÿ‡ป", country: "Tuvalu" }, + { code: "+49", flag: "๐Ÿ‡ฉ๐Ÿ‡ช", country: "Germany" }, + { code: "+65", flag: "๐Ÿ‡ธ๐Ÿ‡ฌ", country: "Singapore" }, + { code: "+381", flag: "๐Ÿ‡ท๐Ÿ‡ธ", country: "Serbia" }, + { code: "+975", flag: "๐Ÿ‡ง๐Ÿ‡น", country: "Bhutan" }, + { code: "+266", flag: "๐Ÿ‡ฑ๐Ÿ‡ธ", country: "Lesotho" }, + { code: "+421", flag: "๐Ÿ‡ธ๐Ÿ‡ฐ", country: "Slovakia" }, + { code: "+1-784", flag: "๐Ÿ‡ป๐Ÿ‡จ", country: "Saint Vincent And The Grenadines" }, + { code: "+673", flag: "๐Ÿ‡ง๐Ÿ‡ณ", country: "Brunei Darussalam" }, + { code: "+509", flag: "๐Ÿ‡ญ๐Ÿ‡น", country: "Haiti" }, + { + code: "+389", + flag: "๐Ÿ‡ฒ๐Ÿ‡ฐ", + country: "Macedonia, The Former Yugoslav Republic Of", + }, + { code: "+886", flag: "๐Ÿ‡น๐Ÿ‡ผ", country: "Taiwan" }, + { code: "+670", flag: "๐Ÿ‡น๐Ÿ‡ฑ", country: "Cocos (Keeling) Islands" }, + { code: "+352", flag: "๐Ÿ‡ฑ๐Ÿ‡บ", country: "Luxembourg" }, + { code: "+880", flag: "๐Ÿ‡ง๐Ÿ‡ฉ", country: "Bangladesh" }, + { code: "+676", flag: "๐Ÿ‡น๐Ÿ‡ด", country: "Tonga" }, + { code: "+681", flag: "๐Ÿ‡ผ๐Ÿ‡ซ", country: "Wallis And Futuna Islands" }, + { code: "+257", flag: "๐Ÿ‡ง๐Ÿ‡ฎ", country: "Burundi" }, + { code: "+502", flag: "๐Ÿ‡ฌ๐Ÿ‡น", country: "Guatemala" }, + { code: "+855", flag: "๐Ÿ‡ฐ๐Ÿ‡ญ", country: "Cambodia" }, + { code: "+235", flag: "๐Ÿ‡น๐Ÿ‡ฉ", country: "Chad" }, + { code: "+216", flag: "๐Ÿ‡น๐Ÿ‡ณ", country: "Tunisia" }, + { code: "+1-242", flag: "๐Ÿ‡ง๐Ÿ‡ธ", country: "Bahamas" }, + { code: "+350", flag: "๐Ÿ‡ฌ๐Ÿ‡ฎ", country: "Gibraltar" }, + { code: "+52", flag: "๐Ÿ‡ฒ๐Ÿ‡ฝ", country: "Mexico" }, + { code: "+856", flag: "๐Ÿ‡ฑ๐Ÿ‡ฆ", country: "Lao People's Democratic Republic" }, + { code: "+680", flag: "๐Ÿ‡ต๐Ÿ‡ผ", country: "Palau" }, + { code: "+249", flag: "๐Ÿ‡ธ๐Ÿ‡ฉ", country: "South Sudan" }, + { code: "+1-340", flag: "๐Ÿ‡ป๐Ÿ‡ฎ", country: "Virgin Islands (U.S.)" }, + { code: "+355", flag: "๐Ÿ‡ฆ๐Ÿ‡ฑ", country: "Albania" }, + { code: "+246", flag: "๐Ÿ‡ฎ๐Ÿ‡ด", country: "British Indian Ocean Territory" }, + { code: "+235", flag: "๐Ÿ‡น๐Ÿ‡ฉ", country: "Chad" }, + { code: "+263", flag: "๐Ÿ‡ฟ๐Ÿ‡ผ", country: "Zimbabwe" }, + { code: "+357", flag: "๐Ÿ‡จ๐Ÿ‡พ", country: "Cyprus" }, + { code: "+350", flag: "๐Ÿ‡ฌ๐Ÿ‡ฎ", country: "Gibraltar" }, + { code: "+256", flag: "๐Ÿ‡บ๐Ÿ‡ฌ", country: "Uganda" }, + { code: "+685", flag: "๐Ÿ‡ผ๐Ÿ‡ธ", country: "Samoa" }, + { code: "+1", flag: "๐Ÿ‡บ๐Ÿ‡ธ", country: "Canada" }, + { code: "+506", flag: "๐Ÿ‡จ๐Ÿ‡ท", country: "Costa Rica" }, + { code: "+34", flag: "๐Ÿ‡ช๐Ÿ‡ธ", country: "Spain" }, + { code: "+684", flag: "๐Ÿ‡ฆ๐Ÿ‡ธ", country: "American Samoa" }, + { code: "+1-268", flag: "๐Ÿ‡ฆ๐Ÿ‡ฌ", country: "Antigua and Barbuda" }, + { code: "+86", flag: "๐Ÿ‡จ๐Ÿ‡ณ", country: "China" }, + { code: "+48", flag: "๐Ÿ‡ต๐Ÿ‡ฑ", country: "Poland" }, + { code: "+974", flag: "๐Ÿ‡ถ๐Ÿ‡ฆ", country: "Qatar" }, + { code: "+36", flag: "๐Ÿ‡ญ๐Ÿ‡บ", country: "Hungary" }, + { code: "+996", flag: "๐Ÿ‡ฐ๐Ÿ‡ฌ", country: "Kyrgyzstan" }, + { code: "+258", flag: "๐Ÿ‡ฒ๐Ÿ‡ฟ", country: "Mozambique" }, + { code: "+675", flag: "๐Ÿ‡ต๐Ÿ‡ฌ", country: "Papua New Guinea" }, + { code: "+41", flag: "๐Ÿ‡จ๐Ÿ‡ญ", country: "Switzerland" }, + { code: "+269", flag: "๐Ÿ‡ฐ๐Ÿ‡ฒ", country: "Comoros" }, + { code: "+230", flag: "๐Ÿ‡ฒ๐Ÿ‡บ", country: "Mauritius" }, + { code: "+60", flag: "๐Ÿ‡ฒ๐Ÿ‡พ", country: "Malaysia" }, + { code: "+228", flag: "๐Ÿ‡น๐Ÿ‡ฌ", country: "Togo" }, + { code: "+994", flag: "๐Ÿ‡ฆ๐Ÿ‡ฟ", country: "Azerbaijan" }, + { code: "+501", flag: "๐Ÿ‡ง๐Ÿ‡ฟ", country: "Belize" }, + { code: "+682", flag: "๐Ÿ‡จ๐Ÿ‡ฐ", country: "Cook Islands" }, + { code: "+1-767", flag: "๐Ÿ‡ฉ๐Ÿ‡ฒ", country: "Dominica" }, + { code: "+372", flag: "๐Ÿ‡ช๐Ÿ‡ช", country: "Estonia" }, + { code: "+220", flag: "๐Ÿ‡ฌ๐Ÿ‡ฒ", country: "Gambia" }, + { code: "+423", flag: "๐Ÿ‡ฑ๐Ÿ‡ฎ", country: "Liechtenstein" }, + { code: "+683", flag: "๐Ÿ‡ณ๐Ÿ‡บ", country: "Niue" }, + { code: "+244", flag: "๐Ÿ‡ฆ๐Ÿ‡ด", country: "Angola" }, + { code: "+241", flag: "๐Ÿ‡ฌ๐Ÿ‡ฆ", country: "Gabon" }, + { code: "+40", flag: "๐Ÿ‡ท๐Ÿ‡ด", country: "Romania" }, + { code: "+966", flag: "๐Ÿ‡ธ๐Ÿ‡ฆ", country: "Saudi Arabia" }, + { code: "+221", flag: "๐Ÿ‡ธ๐Ÿ‡ณ", country: "Senegal" }, + { code: "+232", flag: "๐Ÿ‡ธ๐Ÿ‡ฑ", country: "Sierra Leone" }, + { code: "+262", flag: "๐Ÿ‡น๐Ÿ‡ซ", country: "French Southern Territories" }, + { code: "+670", flag: "๐Ÿ‡น๐Ÿ‡ฑ", country: "Timor-Leste" }, + { code: "+1-284", flag: "๐Ÿ‡ป๐Ÿ‡ฌ", country: "Virgin Islands (British)" }, + { code: "+297", flag: "๐Ÿ‡ฆ๐Ÿ‡ผ", country: "Aruba" }, + { code: "+56", flag: "๐Ÿ‡จ๐Ÿ‡ฑ", country: "Chile" }, + { code: "+53", flag: "๐Ÿ‡จ๐Ÿ‡บ", country: "Cuba" }, + { code: "+595", flag: "๐Ÿ‡ต๐Ÿ‡พ", country: "Paraguay" }, + { code: "+43", flag: "๐Ÿ‡ฆ๐Ÿ‡น", country: "Austria" }, + { code: "+590", flag: "๐Ÿ‡ง๐Ÿ‡ฑ", country: "Saint Barthรฉlemy" }, + { code: "+238", flag: "๐Ÿ‡จ๐Ÿ‡ป", country: "Cape Verde" }, + { code: "+853", flag: "๐Ÿ‡ฒ๐Ÿ‡ด", country: "Macau" }, + { code: "+1-664", flag: "๐Ÿ‡ฒ๐Ÿ‡ธ", country: "Montserrat" }, + { code: "+265", flag: "๐Ÿ‡ฒ๐Ÿ‡ผ", country: "Malawi" }, + { code: "+678", flag: "๐Ÿ‡ป๐Ÿ‡บ", country: "Vanuatu" }, + { code: "+251", flag: "๐Ÿ‡ช๐Ÿ‡น", country: "Ethiopia" }, + { code: "+298", flag: "๐Ÿ‡ซ๐Ÿ‡ด", country: "Faroe Islands" }, + { code: "+224", flag: "๐Ÿ‡ฌ๐Ÿ‡ณ", country: "Guinea" }, + { code: "+30", flag: "๐Ÿ‡ฌ๐Ÿ‡ท", country: "Greece" }, + { code: "+370", flag: "๐Ÿ‡ฑ๐Ÿ‡น", country: "Aaland Islands" }, + { code: "+84", flag: "๐Ÿ‡ป๐Ÿ‡ณ", country: "Viet Nam" }, + { code: "+960", flag: "๐Ÿ‡ฒ๐Ÿ‡ป", country: "Maldives" }, + { code: "+264", flag: "๐Ÿ‡ณ๐Ÿ‡ฆ", country: "Namibia" }, + { code: "+31", flag: "๐Ÿ‡ณ๐Ÿ‡ฑ", country: "Netherlands" }, + { code: "+1-340", flag: "๐Ÿ‡ป๐Ÿ‡ฎ", country: "Virgin Islands (U.S.)" }, + { code: "+374", flag: "๐Ÿ‡ฆ๐Ÿ‡ฒ", country: "Armenia" }, + { code: "+255", flag: "๐Ÿ‡น๐Ÿ‡ฟ", country: "Tanzania, United Republic Of" }, + { code: "+373", flag: "๐Ÿ‡ฒ๐Ÿ‡ฉ", country: "Moldova, Republic Of" }, + { code: "+681", flag: "๐Ÿ‡ผ๐Ÿ‡ซ", country: "Wallis And Futuna Islands" }, + { code: "+46", flag: "๐Ÿ‡ธ๐Ÿ‡ช", country: "Sweden" }, + { code: "+973", flag: "๐Ÿ‡ง๐Ÿ‡ญ", country: "Bahrain" }, + { code: "+32", flag: "๐Ÿ‡ง๐Ÿ‡ช", country: "Belgium" }, + { code: "+61", flag: "๐Ÿ‡ฆ๐Ÿ‡ถ", country: "Christmas Island" }, + { code: "+20", flag: "๐Ÿ‡ช๐Ÿ‡ฌ", country: "Egypt" }, + { code: "+420", flag: "๐Ÿ‡จ๐Ÿ‡ฟ", country: "Czech Republic" }, + { code: "+61", flag: "๐Ÿ‡ฆ๐Ÿ‡บ", country: "Australia" }, + { code: "+1-441", flag: "๐Ÿ‡ง๐Ÿ‡ธ", country: "Bermuda" }, + { code: "+228", flag: "๐Ÿ‡ฌ๐Ÿ‡ฒ", country: "Guernsey" }, +]; + +export type CountryCodeEntry = (typeof COUNTRY_CODES)[number]; + +// Phone number validation regex +export const PHONE_REGEX = /^[\+]?[1-9][\d]{0,15}$/; diff --git a/app/redux/metadata/selectors.ts b/app/redux/metadata/selectors.ts index 89ae029..9a67148 100644 --- a/app/redux/metadata/selectors.ts +++ b/app/redux/metadata/selectors.ts @@ -1,5 +1,7 @@ +import { createSelector } from "@reduxjs/toolkit"; import { RootState } from "../store"; import { FieldGroupMap, SidebarLink } from "./metadataSlice"; +import { COUNTRY_CODES, CountryCodeEntry } from "./constants"; export const selectMetadataState = (state: RootState) => state.metadata; @@ -43,3 +45,54 @@ export const selectTransactionFieldNames = ( state: RootState ): Record | undefined => state.metadata.data?.field_names?.transactions; + +// Re-Selectcrors +const normalizeCountryName = (value: string): string => + value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]/gi, "") + .toLowerCase(); + +const COUNTRY_CODE_LOOKUP = COUNTRY_CODES.reduce< + Record +>((acc, entry) => { + acc[normalizeCountryName(entry.country)] = entry; + return acc; +}, {}); + +const findCountryMetadata = ( + countryName: string +): CountryCodeEntry | undefined => { + const normalized = normalizeCountryName(countryName); + if (!normalized) { + return undefined; + } + + if (COUNTRY_CODE_LOOKUP[normalized]) { + return COUNTRY_CODE_LOOKUP[normalized]; + } + + return COUNTRY_CODES.find(entry => { + const normalizedCountry = normalizeCountryName(entry.country); + return ( + normalizedCountry && + (normalizedCountry.includes(normalized) || + normalized.includes(normalizedCountry)) + ); + }); +}; + +export const selectPhoneNumberCountries = createSelector( + [selectCountries], + countries => + countries.map(country => { + const metadata = findCountryMetadata(country); + + return { + code: metadata?.code ?? "", + flag: metadata?.flag ?? "", + name: country, + }; + }) +); diff --git a/middleware.ts b/middleware.ts index 59b2265..545cd9e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,6 +5,46 @@ import { jwtVerify } from "jose"; const COOKIE_NAME = "auth_token"; const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!); +// Define route-to-role mappings +// Routes can be protected by specific roles/groups or left open to all authenticated users +// Users can have multiple groups, and access is granted if ANY of their groups match +const ROUTE_ROLES: Record = { + // Admin routes - only accessible by Super Admin or admin groups + "/dashboard/admin": ["Super Admin", "Admin"], + "/admin": ["Super Admin", "Admin"], + + // Add more route guards here as needed + // Example: "/dashboard/settings": ["Super Admin", "admin", "manager"], + // Example: "/dashboard/transactions": ["Super Admin", "admin", "operator", "viewer"], +}; + +/** + * Check if a user's groups have access to a specific route + * Returns true if ANY of the user's groups match ANY of the required roles + */ +function hasRouteAccess( + userGroups: string[] | undefined, + pathname: string +): boolean { + // If no role is required for this route, allow access + const requiredRoles = Object.entries(ROUTE_ROLES).find(([route]) => + pathname.startsWith(route) + )?.[1]; + + // If no role requirement found, allow access (route is open to all authenticated users) + if (!requiredRoles) { + return true; + } + + // If user has no groups, deny access + if (!userGroups || userGroups.length === 0) { + return false; + } + + // Check if ANY of the user's groups match ANY of the required roles + return userGroups.some(group => requiredRoles.includes(group)); +} + function isExpired(exp?: number) { return exp ? exp * 1000 <= Date.now() : false; } @@ -17,9 +57,11 @@ async function validateToken(token: string) { algorithms: ["HS256"], }); + console.log("[middleware] payload", payload); return payload as { exp?: number; MustChangePassword?: boolean; + Groups?: string[]; [key: string]: unknown; }; } catch (err) { @@ -67,6 +109,19 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(loginUrl); } + // 5๏ธโƒฃ Role-based route guard (checking Groups array) + const userGroups = (payload.Groups as string[] | undefined) || []; + if (!hasRouteAccess(userGroups, currentPath)) { + // Redirect to dashboard home or unauthorized page + const unauthorizedUrl = new URL("/dashboard", request.url); + unauthorizedUrl.searchParams.set("reason", "unauthorized"); + unauthorizedUrl.searchParams.set( + "message", + "You don't have permission to access this page" + ); + return NextResponse.redirect(unauthorizedUrl); + } + // โœ… All good return NextResponse.next(); }