Pushing to merge latest on server

This commit is contained in:
Mitchell Magro 2025-11-25 10:50:27 +01:00
parent d9485bcf2e
commit a62faab6b5
21 changed files with 1282 additions and 75 deletions

View File

@ -16,6 +16,8 @@ export async function POST(request: Request) {
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
console.log("[LOGIN] resp", resp);
if (!resp.ok) { if (!resp.ok) {
const errJson = await safeJson(resp); const errJson = await safeJson(resp);
return NextResponse.json( return NextResponse.json(

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -0,0 +1,34 @@
export type FilterValue =
| string
| {
operator?: string;
value: string;
};
export const buildFilterParam = (filters: Record<string, FilterValue>) => {
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;
};

View File

@ -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<string, FilterValue> = {
...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 }
);
}
}

View File

@ -0,0 +1,13 @@
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
export default function GroupsPage() {
return (
<AdminResourceList
title="Groups"
endpoint="/api/dashboard/admin/groups"
responseCollectionKeys={["groups", "data", "items"]}
primaryLabelKeys={["name", "groupName", "title"]}
chipKeys={["status", "role", "permissions"]}
/>
);
}

View File

@ -0,0 +1,15 @@
"use client";
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
export default function PermissionsPage() {
return (
<AdminResourceList
title="Permissions"
endpoint="/api/dashboard/admin/permissions"
responseCollectionKeys={["permissions", "data", "items"]}
primaryLabelKeys={["name", "permissionName", "title", "description"]}
chipKeys={["status", "scope", "category"]}
/>
);
}

View File

@ -0,0 +1,13 @@
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
export default function SessionsPage() {
return (
<AdminResourceList
title="Sessions"
endpoint="/api/dashboard/admin/sessions"
responseCollectionKeys={["sessions", "data", "items"]}
primaryLabelKeys={["sessionId", "id", "userId", "name", "title"]}
chipKeys={["status", "channel", "platform"]}
/>
);
}

View File

@ -30,7 +30,7 @@
overflow: hidden; overflow: hidden;
.scroll-wrapper { .scroll-wrapper {
width: 100dvw; width: 85dvw;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;

View File

@ -1,23 +1,100 @@
"use client";
import DataTable from "@/app/features/DataTable/DataTable"; 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({ export default function WithdrawalTransactionPage() {
searchParams, const dispatch = useDispatch<AppDispatch>();
}: { const filters = useSelector(selectFilters);
searchParams: Promise<Record<string, string | string[] | undefined>>; const pagination = useSelector(selectPagination);
}) { const sort = useSelector(selectSort);
// Await searchParams before processing const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
const params = await searchParams; const [rowCount, setRowCount] = useState(0);
// Create a safe query string by filtering only string values
const safeParams: Record<string, string> = {};
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 });
return <DataTable data={data} />; 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 (
<DataTable rows={memoizedRows} enableStatusActions totalRows={rowCount} />
);
} }

View File

@ -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<string, unknown>;
type FilterValue =
| string
| {
operator?: string;
value: string;
};
interface AdminResourceListProps {
title: string;
endpoint: string;
responseCollectionKeys?: string[];
primaryLabelKeys: string[];
chipKeys?: string[];
excludeKeys?: string[];
filterOverrides?: Record<string, FilterValue>;
}
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
const ensureRowId = (
row: Record<string, unknown>,
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<string, unknown>,
preferredKeys: string[] = []
) => {
for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) {
const maybeCollection = payload?.[key];
if (Array.isArray(maybeCollection)) {
return maybeCollection as Record<string, unknown>[];
}
}
if (Array.isArray(payload)) {
return payload as Record<string, unknown>[];
}
return [];
};
const AdminResourceList = ({
title,
endpoint,
responseCollectionKeys = [],
primaryLabelKeys,
chipKeys = [],
excludeKeys = [],
}: AdminResourceListProps) => {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
const status = useSelector(selectStatus);
const errorMessage = useSelector(selectError);
const [rows, setRows] = useState<ResourceRow[]>([]);
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 (
<Box sx={{ p: 3, width: "100%", maxWidth: 900 }}>
<Typography variant="h5" sx={{ mb: 2 }}>
{title}
</Typography>
{status === "loading" && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mb: 2 }}>
<Spinner size="small" color="#000" />
<Typography variant="body2">
{`Loading ${normalizedTitle}...`}
</Typography>
</Box>
)}
{status === "failed" && (
<Alert severity="error" sx={{ mb: 2 }}>
{errorMessage || `Failed to load ${normalizedTitle}`}
</Alert>
)}
{!rows.length && status === "succeeded" && (
<Typography variant="body2" color="text.secondary">
{`No ${normalizedTitle} found.`}
</Typography>
)}
{rows.length > 0 && (
<List
sx={{ bgcolor: "background.paper", borderRadius: 2, boxShadow: 1 }}
>
{rows.map(row => {
const chips = getMetaChips(row);
const secondary = getSecondaryDetails(row);
return (
<Box key={row.id}>
<ListItem alignItems="flex-start">
<Box sx={{ width: "100%" }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 1,
}}
>
<Typography variant="subtitle1" fontWeight={600}>
{getPrimaryLabel(row)}
</Typography>
<Typography variant="caption" color="text.secondary">
ID: {row.id}
</Typography>
</Box>
{chips.length > 0 && (
<Box
sx={{
display: "flex",
gap: 1,
flexWrap: "wrap",
my: 1,
}}
>
{chips.map(chip => (
<Chip
key={`${row.id}-${chip.key}`}
label={`${chip.key}: ${chip.value}`}
size="small"
color="primary"
variant="outlined"
/>
))}
</Box>
)}
{secondary.length > 0 && (
<Typography variant="body2" color="text.secondary">
{secondary
.map(([key, value]) => `${key}: ${String(value)}`)
.join(" • ")}
</Typography>
)}
</Box>
</ListItem>
<Divider component="li" />
</Box>
);
})}
</List>
)}
</Box>
);
};
export default AdminResourceList;

View File

@ -140,7 +140,7 @@ const DataTable = <TRow extends DataRowBase>({
onOpenExport={() => {}} onOpenExport={() => {}}
/> />
<Box sx={{ width: "100%", overflowX: "auto" }}> <Box sx={{ width: "85vw" }}>
<Box sx={{ minWidth: 1200 }}> <Box sx={{ minWidth: 1200 }}>
<DataGrid <DataGrid
rows={localRows} rows={localRows}

View File

@ -15,6 +15,7 @@ import { updateUserDetails } from "@/app/redux/user/userSlice";
const SettingsPersonalInfo: React.FC = () => { const SettingsPersonalInfo: React.FC = () => {
const user = useSelector((state: RootState) => state.auth.user); const user = useSelector((state: RootState) => state.auth.user);
console.log("[SettingsPersonalInfo] user", user);
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -28,8 +29,8 @@ const SettingsPersonalInfo: React.FC = () => {
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setFormData({ setFormData({
first_name: user.firstName ?? "", first_name: user.first_name ?? "",
last_name: user.lastName ?? "", last_name: user.last_name ?? "",
username: user.username ?? "", username: user.username ?? "",
email: user.email ?? "", email: user.email ?? "",
}); });
@ -66,17 +67,20 @@ const SettingsPersonalInfo: React.FC = () => {
<TextField <TextField
label="First Name" label="First Name"
value={formData.first_name} value={formData.first_name}
disabled={true}
onChange={handleChange("first_name")} onChange={handleChange("first_name")}
fullWidth fullWidth
/> />
<TextField <TextField
label="Last Name" label="Last Name"
value={formData.last_name} value={formData.last_name}
disabled={true}
onChange={handleChange("last_name")} onChange={handleChange("last_name")}
fullWidth fullWidth
/> />
<TextField <TextField
label="Username" label="Username"
disabled={true}
value={formData.username} value={formData.username}
onChange={handleChange("username")} onChange={handleChange("username")}
fullWidth fullWidth

View File

@ -1,18 +1,20 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch } from "@/app/redux/store";
import "./AddUser.scss";
import { addUser } from "@/app/redux/auth/authSlice";
import { IEditUserForm } from "../User.interfaces";
import { COUNTRY_CODES } from "../constants";
import { formatPhoneDisplay, validatePhone } from "../utils";
import Spinner from "../../../components/Spinner/Spinner";
import { RootState } from "@/app/redux/store"; import { RootState } from "@/app/redux/store";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch } from "@/app/redux/store";
import { addUser } from "@/app/redux/auth/authSlice";
import { IEditUserForm } from "../User.interfaces";
import { formatPhoneDisplay, validatePhone } from "../utils";
import Spinner from "../../../components/Spinner/Spinner";
import Modal from "@/app/components/Modal/Modal"; import Modal from "@/app/components/Modal/Modal";
import { selectAppMetadata } from "@/app/redux/metadata/selectors"; import {
selectAppMetadata,
selectPhoneNumberCountries,
} from "@/app/redux/metadata/selectors";
import "./AddUser.scss";
interface AddUserProps { interface AddUserProps {
open: boolean; open: boolean;
@ -42,6 +44,8 @@ const AddUser: React.FC<AddUserProps> = ({ open, onClose }) => {
const loading = status === "loading"; const loading = status === "loading";
const COUNTRY_CODES = useSelector(selectPhoneNumberCountries);
const handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => { ) => {
@ -115,7 +119,6 @@ const AddUser: React.FC<AddUserProps> = ({ open, onClose }) => {
if (result && resultAction.payload.success) { if (result && resultAction.payload.success) {
toast.success(resultAction.payload.message); toast.success(resultAction.payload.message);
// router.refresh();
onClose(); onClose();
} }
} catch (err) { } catch (err) {
@ -255,9 +258,12 @@ const AddUser: React.FC<AddUserProps> = ({ open, onClose }) => {
onChange={handleCountryCodeChange} onChange={handleCountryCodeChange}
className="country-code-select" className="country-code-select"
> >
{COUNTRY_CODES.map(country => ( {COUNTRY_CODES.map((country, i) => (
<option key={country.code} value={country.code}> <option
{country.flag} {country.code} {country.country} key={`${country.code}-${country.name} ${i}`}
value={country.code}
>
{country.flag} {country.code} {country.name}
</option> </option>
))} ))}
</select> </select>

View File

@ -5,8 +5,6 @@ import AccountMenu from "./accountMenu/AccountMenu";
import "./Header.scss"; import "./Header.scss";
const Header = () => { const Header = () => {
const handleChange = () => {};
return ( return (
<AppBar <AppBar
className="header" className="header"
@ -17,7 +15,7 @@ const Header = () => {
> >
<Toolbar className="header__toolbar"> <Toolbar className="header__toolbar">
<div className="header__left-group"> <div className="header__left-group">
<Dropdown onChange={handleChange} /> <Dropdown />
</div> </div>
<div className="header__right-group"> <div className="header__right-group">

View File

@ -1,57 +1,137 @@
import React from "react"; import React from "react";
import { import {
FormControl, Button,
InputLabel, Menu,
Select,
MenuItem, MenuItem,
SelectChangeEvent, ListItemText,
ListItemIcon,
Divider,
} from "@mui/material"; } from "@mui/material";
import PageLinks from "../../../../components/PageLinks/PageLinks";
import { useSelector } from "react-redux"; 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 { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
import { SidebarLink } from "@/app/redux/metadata/metadataSlice";
import { resolveIcon } from "@/app/utils/iconMap";
import "./DropDown.scss"; import "./DropDown.scss";
interface Props { interface Props {
onChange?: (event: SelectChangeEvent<string>) => void; onChange?: (path: string) => void;
} }
export default function SidebarDropdown({ onChange }: Props) { export default function SidebarDropdown({ onChange }: Props) {
const [value, setValue] = React.useState(""); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const sidebar = useSelector(selectNavigationSidebar)?.links; const [openMenus, setOpenMenus] = React.useState<Record<string, boolean>>({});
const handleChange = (event: SelectChangeEvent<string>) => { const sidebar = useSelector(selectNavigationSidebar);
setValue(event.target.value); const router = useRouter();
onChange?.(event); const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
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 (
<React.Fragment key={link.title || link.path}>
<MenuItem
onClick={() => toggleMenu(link.title)}
sx={{
pl: `${8 + indent}px`,
}}
>
{Icon && (
<ListItemIcon sx={{ minWidth: 36 }}>
<Icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText primary={link.title} />
{isOpen ? (
<KeyboardArrowDownIcon fontSize="small" />
) : (
<KeyboardArrowRightIcon fontSize="small" />
)}
</MenuItem>
{isOpen &&
link.children?.map(child => renderMenuItem(child, level + 1))}
</React.Fragment>
);
}
return (
<MenuItem
key={link.path}
onClick={() => handleNavigation(link.path)}
sx={{
pl: `${8 + indent}px`,
}}
>
{Icon && (
<ListItemIcon sx={{ minWidth: 36 }}>
<Icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText primary={link.title} />
</MenuItem>
);
}; };
return ( return (
<FormControl fullWidth variant="outlined" sx={{ minWidth: 200 }}> <div>
<InputLabel id="sidebar-dropdown-label">Navigate To</InputLabel> <Button
<Select variant="outlined"
labelId="sidebar-dropdown-label" onClick={handleClick}
value={value} sx={{ minWidth: 200, justifyContent: "space-between" }}
onChange={handleChange} >
label="Navigate To" Navigate To
MenuProps={{ {open ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
PaperProps: { </Button>
style: { <Menu
maxHeight: 200, anchorEl={anchorEl}
}, open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "sidebar-dropdown-button",
}}
PaperProps={{
style: {
maxHeight: 400,
width: "250px",
}, },
}} }}
> >
<em className="em">Select a page</em> <MenuItem disabled>
<MenuItem value="" disabled></MenuItem> <ListItemText primary="Select a page" />
</MenuItem>
<Divider />
<div className="sidebar-dropdown__container"> <div className="sidebar-dropdown__container">
{sidebar?.map((link: SidebarItem) => ( {sidebar?.map(link => renderMenuItem(link))}
<PageLinks
key={link.path}
title={link.title}
path={link.path}
icon={link.icon as string}
/>
))}
</div> </div>
</Select> </Menu>
</FormControl> </div>
); );
} }

View File

@ -10,7 +10,7 @@ export const MainContent = ({ children }: MainContentProps) => {
const isSidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen); const isSidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen);
const style: React.CSSProperties = { const style: React.CSSProperties = {
marginLeft: isSidebarOpen ? "240px" : "30px", marginLeft: isSidebarOpen ? "230px" : "30px",
padding: "24px", padding: "24px",
minHeight: "100vh", minHeight: "100vh",
width: isSidebarOpen ? "calc(100% - 240px)" : "calc(100% - 30px)", width: isSidebarOpen ? "calc(100% - 240px)" : "calc(100% - 30px)",

View File

@ -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}$/;

View File

@ -1,5 +1,7 @@
import { createSelector } from "@reduxjs/toolkit";
import { RootState } from "../store"; import { RootState } from "../store";
import { FieldGroupMap, SidebarLink } from "./metadataSlice"; import { FieldGroupMap, SidebarLink } from "./metadataSlice";
import { COUNTRY_CODES, CountryCodeEntry } from "./constants";
export const selectMetadataState = (state: RootState) => state.metadata; export const selectMetadataState = (state: RootState) => state.metadata;
@ -43,3 +45,54 @@ export const selectTransactionFieldNames = (
state: RootState state: RootState
): Record<string, string> | undefined => ): Record<string, string> | undefined =>
state.metadata.data?.field_names?.transactions; 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<string, CountryCodeEntry>
>((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,
};
})
);

View File

@ -5,6 +5,46 @@ import { jwtVerify } from "jose";
const COOKIE_NAME = "auth_token"; const COOKIE_NAME = "auth_token";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!); 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<string, string[]> = {
// 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) { function isExpired(exp?: number) {
return exp ? exp * 1000 <= Date.now() : false; return exp ? exp * 1000 <= Date.now() : false;
} }
@ -17,9 +57,11 @@ async function validateToken(token: string) {
algorithms: ["HS256"], algorithms: ["HS256"],
}); });
console.log("[middleware] payload", payload);
return payload as { return payload as {
exp?: number; exp?: number;
MustChangePassword?: boolean; MustChangePassword?: boolean;
Groups?: string[];
[key: string]: unknown; [key: string]: unknown;
}; };
} catch (err) { } catch (err) {
@ -67,6 +109,19 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl); 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 // ✅ All good
return NextResponse.next(); return NextResponse.next();
} }