Adding more to registration

This commit is contained in:
Mitchell Magro 2025-10-25 11:39:24 +02:00
parent 247b61f81b
commit 585029e082
96 changed files with 2851 additions and 922 deletions

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
npm test

45
.prettierignore Normal file
View File

@ -0,0 +1,45 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Production builds
.next/
out/
build/
dist/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Package manager files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Generated files
*.tsbuildinfo
next-env.d.ts
# Public assets
public/
# MSW
mockServiceWorker.js
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db

15
.prettierrc Normal file
View File

@ -0,0 +1,15 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"proseWrap": "preserve"
}

View File

@ -19,9 +19,9 @@ A modern backoffice admin panel built with [Next.js](https://nextjs.org/) and [R
## 📦 Getting Started ## 📦 Getting Started
```bash ```bash
git clone https://git.luckyigaming.com/Mitchell/payment-backoffice.git git clone https://git.luckyigaming.com/Mitchell/payment-backoffice.git
cd backoffice cd backoffice
yarn run dev yarn run dev
```

41
app/AuthBootstrap.tsx Normal file
View File

@ -0,0 +1,41 @@
// app/AuthBootstrap.tsx
"use client";
import { useEffect, useRef } from "react";
import { useDispatch } from "react-redux";
import { AppDispatch } from "@/app/redux/types";
import { validateAuth } from "./redux/auth/authSlice";
export function AuthBootstrap() {
const dispatch = useDispatch<AppDispatch>();
const startedRef = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
// Guard against React StrictMode double-invoke in dev
if (startedRef.current) return;
startedRef.current = true;
// Initial validate on mount
dispatch(validateAuth());
// Refresh on window focus (nice UX)
const onFocus = () => dispatch(validateAuth());
window.addEventListener("focus", onFocus);
// Optional: periodic validation every 5 min
intervalRef.current = setInterval(
() => {
dispatch(validateAuth());
},
5 * 60 * 1000
);
return () => {
window.removeEventListener("focus", onFocus);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [dispatch]);
return null;
}

20
app/ReduxProvider.tsx Normal file
View File

@ -0,0 +1,20 @@
"use client";
import React from "react";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./redux/store";
export default function ReduxProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
{children}
</PersistGate>
</Provider>
);
}

View File

@ -0,0 +1,132 @@
import { NextResponse } from "next/server";
import { decodeJwt } from "jose";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
export async function POST(request: Request) {
try {
const { email, currentPassword, newPassword } = await request.json();
// Get the auth token from cookies first
const { cookies } = await import("next/headers");
const cookieStore = cookies();
const token = (await cookieStore).get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: "No authentication token found" },
{ status: 401 }
);
}
// Check MustChangePassword flag from current token
let mustChangePassword = false;
try {
const payload = decodeJwt(token);
mustChangePassword = payload.MustChangePassword || false;
console.log(
"🔍 Current JWT MustChangePassword flag:",
mustChangePassword
);
} catch (err) {
console.error("❌ Failed to decode current JWT:", err);
}
// Skip currentPassword validation if MustChangePassword is true
if (!email || !newPassword || (!mustChangePassword && !currentPassword)) {
return NextResponse.json(
{
success: false,
message: "Email, current password, and new password are required",
},
{ status: 400 }
);
}
// 🔁 Call backend API with the correct payload
const requestBody: {
email: string;
new_password: string;
current_password?: string;
} = {
email,
new_password: newPassword,
};
// Only include current_password if MustChangePassword is false
if (!mustChangePassword && currentPassword) {
requestBody.current_password = currentPassword;
}
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
});
const data = await resp.json();
if (!resp.ok) {
console.log("[DEBUG] [CHANGE-PASSWORD] Error response:", {
status: resp.status,
data,
});
return NextResponse.json(
{ success: false, message: data?.message || "Password change failed" },
{ status: resp.status }
);
}
// ✅ Handle new token from backend
const newToken = data?.token;
const response = NextResponse.json({
success: true,
message: data?.message || "Password changed successfully",
});
if (newToken) {
try {
const payload = decodeJwt(newToken);
console.log("🔍 New JWT payload:", payload);
console.log(
"🔍 must_change_password flag:",
payload.must_change_password
);
} catch (err) {
console.error("❌ Failed to decode new JWT:", err);
}
// Derive maxAge from JWT exp if available; fallback to 12h
let maxAge = 60 * 60 * 12;
try {
const payload = decodeJwt(newToken);
if (payload?.exp) {
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
if (secondsLeft > 0) maxAge = secondsLeft;
}
} catch {}
response.cookies.set({
name: COOKIE_NAME,
value: newToken,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge,
});
}
return response;
} catch (error) {
console.error("❌ Change password proxy error:", error);
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -1,64 +1,92 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { SignJWT } from "jose"; import { decodeJwt } from "jose";
// Secret key for JWT signing (in production, use environment variable) const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET); const COOKIE_NAME = "auth_token";
const TOKEN_EXPIRY = 60 * 60 * 12; // 12 hours (in seconds)
// This is your POST handler for the login endpoint
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { email, password } = await request.json(); const { email, password } = await request.json();
// --- Replace with your ACTUAL authentication logic --- // Call backend login
// In a real application, you would: const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/login`, {
// 1. Query your database for the user by email. method: "POST",
// 2. Hash the provided password and compare it to the stored hashed password. headers: { "Content-Type": "application/json" },
// 3. If credentials match, generate a secure JWT (JSON Web Token) or session ID. body: JSON.stringify({ email, password }),
// 4. Store the token/session ID securely (e.g., in a database or Redis). });
// Mock authentication for demonstration purposes:
if (email === "admin@example.com" && password === "password123") {
// Create JWT token with expiration
const token = await new SignJWT({
email,
role: "admin",
iat: Math.floor(Date.now() / 1000), // issued at
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(Math.floor(Date.now() / 1000) + TOKEN_EXPIRY)
.sign(JWT_SECRET);
// Set the authentication token as an HTTP-only cookie
// HTTP-only cookies are crucial for security as they cannot be accessed by client-side JavaScript,
// which mitigates XSS attacks.
const cookieStore = await cookies();
cookieStore.set("auth_token", token, {
httpOnly: true, // IMPORTANT: Makes the cookie inaccessible to client-side scripts
secure: process.env.NODE_ENV === "production", // Use secure in production (HTTPS)
maxAge: TOKEN_EXPIRY, // 24 hours
path: "/", // Available across the entire site
sameSite: "lax", // Protects against CSRF
});
if (!resp.ok) {
const errJson = await safeJson(resp);
return NextResponse.json( return NextResponse.json(
{ success: true, message: "Login successful" }, { success: false, message: errJson?.message || "Login failed" },
{ status: 200 } { status: resp.status }
);
} else {
return NextResponse.json(
{ success: false, message: "Invalid credentials" },
{ status: 401 }
); );
} }
const data = await resp.json();
const token: string | undefined = data?.token;
if (!token) {
return NextResponse.json(
{ success: false, message: "No token returned from backend" },
{ status: 502 }
);
}
// Decode JWT token to extract MustChangePassword
let mustChangePassword = false;
let maxAge = 60 * 60 * 12; // fallback to 12h
try {
const payload = decodeJwt(token);
// Extract exp if present
if (payload?.exp) {
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
if (secondsLeft > 0) maxAge = secondsLeft;
}
// Extract MustChangePassword flag if it exists
if (typeof payload?.MustChangePassword === "boolean") {
mustChangePassword = payload.MustChangePassword;
}
} catch (err) {
console.warn("Failed to decode JWT:", err);
}
// Set the cookie
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge,
});
return NextResponse.json(
{
user: data.user,
success: true,
message: "Login successful",
must_change_password: mustChangePassword,
},
{ status: 200 }
);
} catch (error) { } catch (error) {
console.error("Login API error:", error); console.error("Login proxy error:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Internal server error" }, { success: false, message: "Internal server error" },
{ status: 500 } { status: 500 }
); );
} }
} }
async function safeJson(resp: Response) {
try {
return await resp.json();
} catch {
return null;
}
}

View File

@ -1,24 +1,28 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
// This is your DELETE handler for the logout endpoint const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:3000";
export async function DELETE() { const COOKIE_NAME = "auth_token";
try {
// Clear the authentication cookie.
// This MUST match the name of the cookie set during login.
// In your login handler, the cookie is named "auth_token".
const cookieStore = await cookies();
cookieStore.delete("auth_token");
return NextResponse.json( export async function DELETE() {
{ success: true, message: "Logged out successfully" }, const cookieStore = await cookies();
{ status: 200 }, const token = cookieStore.get(COOKIE_NAME)?.value;
);
} catch (error) { if (token) {
console.error("Logout API error:", error); try {
return NextResponse.json( await fetch(`${BE_BASE_URL}/logout`, {
{ success: false, message: "Internal server error during logout" }, method: "POST",
{ status: 500 }, headers: {
); "Content-Type": "application/json",
Authorization: `Bearer ${token}`, // satisfy requireJwt
},
body: JSON.stringify({ token }), // satisfy body check
});
} catch (err) {
console.error("BE /logout failed:", err);
}
} }
cookieStore.delete(COOKIE_NAME);
return NextResponse.json({ success: true, message: "Logged out" });
} }

View File

@ -0,0 +1,138 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { randomUUID } from "crypto";
import { formatPhoneDisplay } from "@/app/features/UserRoles/utils";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
// Interface matching the backend RegisterRequest
interface RegisterRequest {
creator: string;
email: string;
first_name: string;
groups: string[];
job_title: string;
last_name: string;
merchants: string[];
phone: string;
username: string;
}
// Frontend form interface
interface FrontendRegisterForm {
email: string;
firstName: string;
lastName: string;
username: string;
phone?: string;
jobTitle?: string;
groups?: string[];
merchants?: string[];
creator?: string;
}
export async function POST(request: Request) {
try {
const body: FrontendRegisterForm = await request.json();
// Get the auth token from cookies
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{
success: false,
message: "No authentication token found",
},
{ status: 401 }
);
}
// Validate required fields
const requiredFields = ["email", "firstName", "lastName", "username"];
const missingFields = requiredFields.filter(
field => !body[field as keyof FrontendRegisterForm]
);
if (missingFields.length > 0) {
return NextResponse.json(
{
success: false,
message: `Missing required fields: ${missingFields.join(", ")}`,
},
{ status: 400 }
);
}
// Map frontend payload to backend RegisterRequest format
const registerPayload: RegisterRequest = {
creator: body.creator || randomUUID(), // Generate UUID if not provided
email: body.email,
first_name: body.firstName,
groups: body.groups || ["Reader"], // Default to empty array if not provided
job_title: body.jobTitle || "Reader",
last_name: body.lastName,
merchants: body.merchants || ["Win Bot"], // Default to empty array if not provided
phone: body.phone ? formatPhoneDisplay(body.phone, body.countryCode) : "",
username: body.username,
};
// Call backend registration endpoint
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(registerPayload),
});
console.log("[DEBUG] [REGISTER-PAYLOAD]: ", registerPayload);
// Handle backend response
if (!resp.ok) {
const errorData = await safeJson(resp);
console.log("[DEBUG] [REGISTER-ERROR]: ", errorData);
return NextResponse.json(
{
success: false,
message: errorData?.message || "Registration failed",
},
{ status: resp.status }
);
}
const data = await resp.json();
console.log("[DEBUG] [REGISTER]: ", data);
return NextResponse.json(
{
success: true,
message: "Registration successful",
user: data.user || null,
},
{ status: 201 }
);
} catch (error) {
console.error("Registration proxy error:", error);
return NextResponse.json(
{
success: false,
message: "Internal server error during registration",
},
{ status: 500 }
);
}
}
// Helper function to safely parse JSON responses
async function safeJson(resp: Response) {
try {
return await resp.json();
} catch {
return null;
}
}

View File

@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
export async function POST() {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ valid: false }, { status: 401 });
}
try {
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ token }),
});
const data = await resp.json();
return NextResponse.json(data, { status: resp.status });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ valid: false, message: "Validation failed", error: message },
{ status: 500 }
);
}
}

View File

@ -1,46 +1,45 @@
import { NextRequest, NextResponse } from "next/server"; // app/api/users/[id]/route.ts
import { users, type User } from "../../mockData"; // adjust relative path import { NextResponse } from "next/server";
type UpdateUserBody = Partial<{ const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
firstName: string; const COOKIE_NAME = "auth_token";
lastName: string;
email: string;
phone: string;
role: string;
}>;
export async function PUT( export async function PATCH(
request: NextRequest, request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: { id: string } }
) { ) {
const { id } = await params;
let body: unknown;
try { try {
body = await request.json(); console.log("[PATCH /users] - params", params);
} catch { const { id } = params;
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); const body = await request.json();
// Get the auth token from cookies
const { cookies } = await import("next/headers");
const cookieStore = cookies();
const token = (await cookieStore).get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ message: "Missing Authorization header" },
{ status: 401 }
);
}
const response = await fetch(`${BE_BASE_URL}/users/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (err: any) {
console.error("Proxy PATCH /users error:", err);
return NextResponse.json(
{ message: "Internal server error", error: err.message },
{ status: 500 }
);
} }
const { firstName, lastName, email, phone, role } = body as UpdateUserBody;
const userIndex = users.findIndex((u: User) => u.id === id);
if (userIndex === -1) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const existingUser = users[userIndex];
const updatedUser: User = {
...existingUser,
firstName: firstName ?? existingUser.firstName,
lastName: lastName ?? existingUser.lastName,
email: email ?? existingUser.email,
phone: phone ?? existingUser.phone,
authorities: role ? [role] : existingUser.authorities ?? [],
};
users[userIndex] = updatedUser;
return NextResponse.json(updatedUser, { status: 200 });
} }

View File

@ -1,44 +0,0 @@
// app/api/dashboard/admin/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { users } from "../mockData";
export async function GET() {
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const { firstName, lastName, email, phone, role } = body;
// Add the new user to the existing users array (in-memory, not persistent)
const newUser = {
merchantId: 100987998,
name: "Jacob",
id: "382eed15-1e21-41fa-b1f3-0c1adb3af714",
username: "lsterence",
firstName,
lastName,
email,
phone,
jobTitle: "",
role,
enabled: true,
authorities: ["ROLE_IIN", "ROLE_FIRST_APPROVER", "ROLE_RULES_ADMIN"],
allowedMerchantIds: [100987998],
created: "2025-05-04T15:32:48.432Z",
disabledBy: null,
disabledDate: null,
disabledReason: null,
incidentNotes: false,
lastLogin: "",
lastMandatoryUpdated: "2025-05-04T15:32:48.332Z",
marketingNewsletter: false,
releaseNotes: false,
requiredActions: ["CONFIGURE_TOTP", "UPDATE_PASSWORD"],
twoFactorCondition: "required",
twoFactorCredentials: [],
};
users.push(newUser);
return NextResponse.json(users, { status: 201 });
}

View File

@ -7,8 +7,8 @@ export async function GET(request: NextRequest) {
let filteredApproveRows = [...approveRows]; let filteredApproveRows = [...approveRows];
if (merchantId) { if (merchantId) {
filteredApproveRows = filteredApproveRows.filter((tx) => filteredApproveRows = filteredApproveRows.filter(tx =>
tx.merchantId.toString().includes(merchantId), tx.merchantId.toString().includes(merchantId)
); );
} }

View File

@ -16,23 +16,22 @@ export async function GET(request: NextRequest) {
if (actionType) { if (actionType) {
filteredRows = filteredRows.filter( filteredRows = filteredRows.filter(
(tx) => tx => tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase()
tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase(),
); );
} }
if (affectedUserId) { if (affectedUserId) {
filteredRows = filteredRows.filter( filteredRows = filteredRows.filter(
(tx) => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase(), tx => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase()
); );
} }
if (adminId) { if (adminId) {
filteredRows = filteredRows.filter((tx) => tx.adminId === adminId); filteredRows = filteredRows.filter(tx => tx.adminId === adminId);
} }
if (adminUsername) { if (adminUsername) {
filteredRows = filteredRows.filter( filteredRows = filteredRows.filter(
(tx) => tx.adminUsername === adminUsername, tx => tx.adminUsername === adminUsername
); );
} }
@ -46,11 +45,11 @@ export async function GET(request: NextRequest) {
{ {
error: "Invalid date range", error: "Invalid date range",
}, },
{ status: 400 }, { status: 400 }
); );
} }
filteredRows = filteredRows.filter((tx) => { filteredRows = filteredRows.filter(tx => {
const txDate = new Date(tx.timeStampOfTheAction); const txDate = new Date(tx.timeStampOfTheAction);
// Validate if the timestamp is a valid date // Validate if the timestamp is a valid date

View File

@ -232,8 +232,7 @@ export const allTransactionsExtraColumns: GridColDef[] = [
{ field: "fraudScore", headerName: "Fraud Score", width: 130 }, { field: "fraudScore", headerName: "Fraud Score", width: 130 },
]; ];
export const extraColumns = ["currency", "errorInfo", "fraudScore"] export const extraColumns = ["currency", "errorInfo", "fraudScore"];
export const allTransactionsSearchLabels = [ export const allTransactionsSearchLabels = [
{ label: "User", field: "userId", type: "text" }, { label: "User", field: "userId", type: "text" },

View File

@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { allTransactionDummyData, allTransactionsColumns, allTransactionsSearchLabels, extraColumns } from "./mockData"; import {
allTransactionDummyData,
allTransactionsColumns,
allTransactionsSearchLabels,
extraColumns,
} from "./mockData";
// import { formatToDateTimeString } from "@/app/utils/formatDate"; // import { formatToDateTimeString } from "@/app/utils/formatDate";
@ -20,29 +25,29 @@ export async function GET(request: NextRequest) {
if (userId) { if (userId) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.userId.toString() === userId, tx => tx.userId.toString() === userId
); );
} }
if (status) { if (status) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.status.toLowerCase() === status.toLowerCase(), tx => tx.status.toLowerCase() === status.toLowerCase()
); );
} }
if (depositMethod) { if (depositMethod) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase(), tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
); );
} }
if (merchandId) { if (merchandId) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.merchandId.toString() === merchandId, tx => tx.merchandId.toString() === merchandId
); );
} }
if (transactionId) { if (transactionId) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.transactionId.toString() === transactionId, tx => tx.transactionId.toString() === transactionId
); );
} }
@ -55,11 +60,11 @@ export async function GET(request: NextRequest) {
{ {
error: "Invalid date range", error: "Invalid date range",
}, },
{ status: 400 }, { status: 400 }
); );
} }
filteredTransactions = filteredTransactions.filter((tx) => { filteredTransactions = filteredTransactions.filter(tx => {
const txDate = new Date(tx.dateTime); const txDate = new Date(tx.dateTime);
if (isNaN(txDate.getTime())) { if (isNaN(txDate.getTime())) {

View File

@ -24,29 +24,29 @@ export async function GET(request: NextRequest) {
if (userId) { if (userId) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.userId.toString() === userId, tx => tx.userId.toString() === userId
); );
} }
if (status) { if (status) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.status.toLowerCase() === status.toLowerCase(), tx => tx.status.toLowerCase() === status.toLowerCase()
); );
} }
if (depositMethod) { if (depositMethod) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase(), tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
); );
} }
if (merchandId) { if (merchandId) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.merchandId.toString() === merchandId, tx => tx.merchandId.toString() === merchandId
); );
} }
if (transactionId) { if (transactionId) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.transactionId.toString() === transactionId, tx => tx.transactionId.toString() === transactionId
); );
} }
@ -59,11 +59,11 @@ export async function GET(request: NextRequest) {
{ {
error: "Invalid date range", error: "Invalid date range",
}, },
{ status: 400 }, { status: 400 }
); );
} }
filteredTransactions = filteredTransactions.filter((tx) => { filteredTransactions = filteredTransactions.filter(tx => {
const txDate = new Date(tx.dateTime); const txDate = new Date(tx.dateTime);
if (isNaN(txDate.getTime())) { if (isNaN(txDate.getTime())) {

View File

@ -7,7 +7,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Error", status: "Error",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -26,7 +26,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Error", status: "Error",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -45,7 +45,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Completed", status: "Completed",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -62,7 +62,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049136973, transactionId: 1049136973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Completed", status: "Completed",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -79,7 +79,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Error", status: "Error",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -98,7 +98,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Error", status: "Error",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -117,7 +117,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Error", status: "Error",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -136,7 +136,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Card", withdrawalMethod: "Card",
status: "Pending", status: "Pending",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -153,7 +153,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Inprogress", status: "Inprogress",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -170,7 +170,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Error", status: "Error",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -189,7 +189,7 @@ export const withdrawalTransactionDummyData = [
transactionId: 1049131973, transactionId: 1049131973,
withdrawalMethod: "Bank Transfer", withdrawalMethod: "Bank Transfer",
status: "Error", status: "Error",
options: [ options: [
{ value: "Pending", label: "Pending" }, { value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" }, { value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" }, { value: "Inprogress", label: "Inprogress" },
@ -209,7 +209,7 @@ export const withdrawalTransactionsColumns: GridColDef[] = [
{ field: "transactionId", headerName: "Transaction ID", width: 130 }, { field: "transactionId", headerName: "Transaction ID", width: 130 },
{ field: "withdrawalMethod", headerName: "Withdrawal Method", width: 130 }, { field: "withdrawalMethod", headerName: "Withdrawal Method", width: 130 },
{ field: "status", headerName: "Status", width: 130 }, { field: "status", headerName: "Status", width: 130 },
{ field: "actions", headerName: "Actions", width: 150 }, { field: "actions", headerName: "Actions", width: 150 },
{ field: "amount", headerName: "Amount", width: 130 }, { field: "amount", headerName: "Amount", width: 130 },
{ field: "dateTime", headerName: "Date / Time", width: 130 }, { field: "dateTime", headerName: "Date / Time", width: 130 },
{ field: "errorInfo", headerName: "Error Info", width: 130 }, { field: "errorInfo", headerName: "Error Info", width: 130 },

View File

@ -19,13 +19,13 @@ export async function GET(request: NextRequest) {
if (userId) { if (userId) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.userId.toString() === userId, tx => tx.userId.toString() === userId
); );
} }
if (status) { if (status) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.status.toLowerCase() === status.toLowerCase(), tx => tx.status.toLowerCase() === status.toLowerCase()
); );
} }
@ -38,11 +38,11 @@ export async function GET(request: NextRequest) {
{ {
error: "Invalid date range", error: "Invalid date range",
}, },
{ status: 400 }, { status: 400 }
); );
} }
filteredTransactions = filteredTransactions.filter((tx) => { filteredTransactions = filteredTransactions.filter(tx => {
const txDate = new Date(tx.dateTime); const txDate = new Date(tx.dateTime);
if (isNaN(txDate.getTime())) { if (isNaN(txDate.getTime())) {
@ -55,8 +55,7 @@ export async function GET(request: NextRequest) {
if (withdrawalMethod) { if (withdrawalMethod) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx => tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase()
tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase(),
); );
} }

View File

@ -1,65 +1,83 @@
interface Transaction { interface Transaction {
userId: number | string; userId: number | string;
date: string; date: string;
method: string; method: string;
amount: number | string; amount: number | string;
status: string; status: string;
} }
export const deposits: Transaction[] = [ export const deposits: Transaction[] = [
{ userId: 17, date: "2025-08-01 10:10", method: "CC", amount: 120, status: "approved" }, {
{ userId: 17,
userId: 17, date: "2025-08-01 10:10",
date: "2025-07-28 14:35", method: "CC",
method: "Bank Transfer", amount: 120,
amount: 250, status: "approved",
status: "approved", },
}, {
{ userId: 17,
userId: 17, date: "2025-07-28 14:35",
date: "2025-07-20 09:05", method: "Bank Transfer",
method: "PayPal", amount: 250,
amount: 75, status: "approved",
status: "pending", },
}, {
{ userId: 17, userId: 17,
date: "2025-07-11 17:12", method: "CC", amount: 300, status: "rejected" }, date: "2025-07-20 09:05",
{ userId: 17, method: "PayPal",
date: "2025-07-01 12:42", method: "CC", amount: 180, status: "approved" }, amount: 75,
]; status: "pending",
},
{
userId: 17,
date: "2025-07-11 17:12",
method: "CC",
amount: 300,
status: "rejected",
},
{
userId: 17,
date: "2025-07-01 12:42",
method: "CC",
amount: 180,
status: "approved",
},
];
export const withdrawals: Transaction[] = [ export const withdrawals: Transaction[] = [
{ {
userId: 17, userId: 17,
date: "2025-08-02 11:20", date: "2025-08-02 11:20",
method: "Crypto", method: "Crypto",
amount: 95, amount: 95,
status: "processing", status: "processing",
}, },
{ {
userId: 17, userId: 17,
date: "2025-07-29 16:45", date: "2025-07-29 16:45",
method: "Bank Transfer", method: "Bank Transfer",
amount: 220, amount: 220,
status: "approved", status: "approved",
}, },
{ {
userId: 17, userId: 17,
date: "2025-07-21 15:10", date: "2025-07-21 15:10",
method: "eWallet", method: "eWallet",
amount: 60, amount: 60,
status: "pending", status: "pending",
}, },
{ userId: 17, {
date: "2025-07-12 13:33", userId: 17,
method: "Crypto", date: "2025-07-12 13:33",
amount: 120, method: "Crypto",
status: "approved", amount: 120,
}, status: "approved",
{ userId: 17, },
date: "2025-07-03 08:50", {
method: "Bank Transfer", userId: 17,
amount: 150, date: "2025-07-03 08:50",
status: "rejected", method: "Bank Transfer",
}, amount: 150,
]; status: "rejected",
},
];

View File

@ -7,12 +7,15 @@ export async function GET(request: NextRequest) {
let filteredDeposits = [...deposits]; let filteredDeposits = [...deposits];
let filteredwithdrawals = [...withdrawals]; let filteredwithdrawals = [...withdrawals];
if( userId ){ if (userId) {
filteredDeposits = filteredDeposits.filter((item) => item.userId.toString() === userId.toString()) filteredDeposits = filteredDeposits.filter(
filteredwithdrawals = filteredwithdrawals.filter((item) => item.userId.toString() === userId.toString()) item => item.userId.toString() === userId.toString()
);
filteredwithdrawals = filteredwithdrawals.filter(
item => item.userId.toString() === userId.toString()
);
} }
return NextResponse.json({ return NextResponse.json({
deposits: filteredDeposits, deposits: filteredDeposits,
withdrawals: filteredwithdrawals, withdrawals: filteredwithdrawals,

View File

View File

@ -17,7 +17,7 @@
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
position: relative; position: relative;
min-width: 320px; min-width: 320px;
max-width: 90vw; max-width: 60vw;
max-height: 90vh; max-height: 90vh;
overflow: auto; overflow: auto;
padding: 2rem 1.5rem 1.5rem 1.5rem; padding: 2rem 1.5rem 1.5rem 1.5rem;
@ -49,5 +49,5 @@
margin-top: 1rem; margin-top: 1rem;
font-size: 1rem; font-size: 1rem;
color: #222; color: #222;
width: 500px; min-width: 450px;
} }

View File

@ -27,7 +27,7 @@ const Modal: React.FC<ModalProps> = ({
> >
<div <div
className={`modal${className ? " " + className : ""}`} className={`modal${className ? " " + className : ""}`}
onClick={(e) => e.stopPropagation()} onClick={e => e.stopPropagation()}
data-testid="modal-content" data-testid="modal-content"
> >
<button <button

View File

@ -1,20 +1,20 @@
.page-link__container { .page-link__container {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 1px; padding: 12px 1px;
border-radius: 4px; border-radius: 4px;
color: var(--text-tertiary); color: var(--text-tertiary);
text-decoration: none; text-decoration: none;
transition: background 0.2s ease-in-out; transition: background 0.2s ease-in-out;
&:hover { &:hover {
color: #fff; color: #fff;
background-color: var(--hover-color); background-color: var(--hover-color);
cursor: pointer; cursor: pointer;
} }
.page-link__text { .page-link__text {
color: var(--text-tertiary); color: var(--text-tertiary);
margin-left: 12px; margin-left: 12px;
font-weight: 500; font-weight: 500;
} }
} }

View File

@ -1,43 +1,43 @@
.search-filters { .search-filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
padding: 16px; padding: 16px;
} }
.chip { .chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background-color: #e0e0e0; background-color: #e0e0e0;
border-radius: 16px; border-radius: 16px;
padding: 4px 8px; padding: 4px 8px;
margin: 4px; margin: 4px;
font-size: 14px; font-size: 14px;
} }
.chip-label { .chip-label {
margin-right: 8px; margin-right: 8px;
} }
.chip-label.bold { .chip-label.bold {
font-weight: bold; font-weight: bold;
} }
.chip-delete { .chip-delete {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 16px;
line-height: 1; line-height: 1;
color: #333; color: #333;
} }
.clear-all { .clear-all {
margin-left: 8px; margin-left: 8px;
text-decoration: underline; text-decoration: underline;
background: none; background: none;
border: none; border: none;
color: black; color: black;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
} }

View File

@ -50,7 +50,7 @@ const SearchFilters = ({ filters }: SearchFiltersProps) => {
const allFilters = [ const allFilters = [
...Object.entries(filters).filter( ...Object.entries(filters).filter(
([key]) => key !== "dateTime_start" && key !== "dateTime_end", ([key]) => key !== "dateTime_start" && key !== "dateTime_end"
), ),
...(hasDateRange ...(hasDateRange
? [ ? [
@ -65,7 +65,7 @@ const SearchFilters = ({ filters }: SearchFiltersProps) => {
return ( return (
<div className="search-filters"> <div className="search-filters">
{allFilters.map(([key, value]) => {allFilters.map(([key, value]) =>
value ? renderChip(filterLabels[key] ?? key, value, key) : null, value ? renderChip(filterLabels[key] ?? key, value, key) : null
)} )}
{Object.values(filters).some(Boolean) && ( {Object.values(filters).some(Boolean) && (

View File

@ -1,18 +1,17 @@
import Users from "@/app/features/Pages/Admin/Users/users"; import Users from "@/app/features/Pages/Admin/Users/users";
export default async function BackOfficeUsersPage() { export default async function BackOfficeUsersPage() {
const baseUrl = const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
process.env.NEXT_PUBLIC_BASE_URL ? `${process.env.NEXT_PUBLIC_BASE_URL}`
? `${process.env.NEXT_PUBLIC_BASE_URL}` : "http://localhost:4000";
: "http://localhost:3000"; // const res = await fetch(`${baseUrl}/api/dashboard/admin/users`, {
const res = await fetch(`${baseUrl}/api/dashboard/admin/users`, { // cache: "no-store", // 👈 disables caching for SSR freshness
cache: "no-store", // 👈 disables caching for SSR freshness // });
}); // const users = await res.json();
const users = await res.json();
return ( return (
<div> <div>
<Users users={users} /> <Users users={[]} />
</div> </div>
); );
} }

View File

@ -1,9 +1,6 @@
import { import { ApproveTable } from "@/app/features/Pages/Approve/Approve";
ApproveTable,
} from "@/app/features/Pages/Approve/Approve";
import { getApproves } from "@/app/services/approve"; import { getApproves } from "@/app/services/approve";
export default async function Approve({ export default async function Approve({
searchParams, searchParams,
}: { }: {
@ -21,8 +18,5 @@ export default async function Approve({
const query = new URLSearchParams(safeParams).toString(); const query = new URLSearchParams(safeParams).toString();
const data = await getApproves({ query }); const data = await getApproves({ query });
return ( return <ApproveTable data={data} />;
<ApproveTable data={data} /> }
);
};

View File

@ -1,7 +1,7 @@
// This ensures this component is rendered only on the client side // This ensures this component is rendered only on the client side
'use client'; "use client";
import React from 'react'; import React from "react";
export default function InvestigatePage() { export default function InvestigatePage() {
return ( return (

View File

@ -0,0 +1,21 @@
import SettingsPageClient from "@/app/features/Pages/Settings/SettingsPageClient";
// We can enable this if we want to fetch the user from the database by the id but it's not necessary since we already have the user in the redux store
// async function getUser() {
// const token = (await cookies()).get("auth_token")?.value;
// const payload = token ? await validateToken(token) : null;
// const userId = payload?.id; // requires JWT to include id
// if (!userId) throw new Error("No user id in token");
// const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/dashboard/admin/users/${userId}`, {
// headers: { Authorization: `Bearer ${token}` },
// cache: "no-store",
// });
// if (!res.ok) throw new Error("Failed to fetch user");
// return res.json();
// }
export default async function SettingsPage() {
// const user = await getUser();
return <SettingsPageClient />;
}

View File

@ -1,7 +1,7 @@
.account-iq { .account-iq {
.account-iq__icon { .account-iq__icon {
font-weight: bold; font-weight: bold;
color: #4ecdc4; color: #4ecdc4;
margin-top: 4px; margin-top: 4px;
} }
} }

View File

@ -40,7 +40,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
}); });
router.push(`?${updatedParams.toString()}`); router.push(`?${updatedParams.toString()}`);
}, 500), }, 500),
[router], [router]
); );
const handleFieldChange = (field: string, value: string) => { const handleFieldChange = (field: string, value: string) => {
@ -132,9 +132,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
fullWidth fullWidth
size="small" size="small"
value={formValues[field] || ""} value={formValues[field] || ""}
onChange={(e) => onChange={e => handleFieldChange(field, e.target.value)}
handleFieldChange(field, e.target.value)
}
/> />
)} )}
@ -142,7 +140,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<Select <Select
value={formValues[field] || ""} value={formValues[field] || ""}
onChange={(e) => onChange={e =>
handleFieldChange(field, e.target.value) handleFieldChange(field, e.target.value)
} }
displayEmpty displayEmpty
@ -150,7 +148,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
<MenuItem value=""> <MenuItem value="">
<em>{label}</em> <em>{label}</em>
</MenuItem> </MenuItem>
{options?.map((option) => ( {options?.map(option => (
<MenuItem value={option} key={option}> <MenuItem value={option} key={option}>
{option} {option}
</MenuItem> </MenuItem>
@ -168,14 +166,14 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
? new Date(formValues[`${field}_start`]) ? new Date(formValues[`${field}_start`])
: null : null
} }
onChange={(newValue) => { onChange={newValue => {
if (!newValue) if (!newValue)
return handleFieldChange(`${field}_start`, ""); return handleFieldChange(`${field}_start`, "");
const start = new Date(newValue); const start = new Date(newValue);
start.setHours(0, 0, 0, 0); // force start of day start.setHours(0, 0, 0, 0); // force start of day
handleFieldChange( handleFieldChange(
`${field}_start`, `${field}_start`,
start.toISOString(), start.toISOString()
); );
}} }}
slotProps={{ slotProps={{
@ -189,14 +187,14 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
? new Date(formValues[`${field}_end`]) ? new Date(formValues[`${field}_end`])
: null : null
} }
onChange={(newValue) => { onChange={newValue => {
if (!newValue) if (!newValue)
return handleFieldChange(`${field}_end`, ""); return handleFieldChange(`${field}_end`, "");
const end = new Date(newValue); const end = new Date(newValue);
end.setHours(23, 59, 59, 999); // force end of day end.setHours(23, 59, 59, 999); // force end of day
handleFieldChange( handleFieldChange(
`${field}_end`, `${field}_end`,
end.toISOString(), end.toISOString()
); );
}} }}
slotProps={{ slotProps={{

View File

@ -0,0 +1,208 @@
/* app/styles/LoginModal.scss (BEM Methodology) */
@use "sass:color";
// Variables for consistent styling
$primary-color: #2563eb; // Blue-600 equivalent
$primary-hover-color: #1d4ed8; // Blue-700 equivalent
$success-color: #16a34a; // Green-600 equivalent
$error-color: #dc2626; // Red-600 equivalent
$text-color-dark: #1f2937; // Gray-800 equivalent
$text-color-medium: #4b5563; // Gray-700 equivalent
$text-color-light: #6b7280; // Gray-600 equivalent
$border-color: #d1d5db; // Gray-300 equivalent
$bg-color-light: #f3f4f6; // Gray-100 equivalent
$bg-color-white: #ffffff;
/* --- Login Modal Block (.login-modal) --- */
.login-modal__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(17, 24, 39, 0.75); // Gray-900 75% opacity
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem; // p-4
}
.login-modal__content {
background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04); // shadow-2xl
padding: 2rem; // p-8
width: 100%;
max-width: 28rem; // max-w-md
transform: scale(1);
opacity: 1;
transition: all 0.3s ease-in-out; // transition-all duration-300
}
.login-modal__title {
font-size: 1.875rem; // text-3xl
font-weight: 700; // font-bold
color: $text-color-dark;
margin-bottom: 1.5rem; // mb-6
text-align: center;
}
/* --- Login Form Block (.login-form) --- */
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem; // space-y-6
}
.login-form__group {
// No specific styles needed here, just a container for label/input
}
.login-form__label {
display: block;
font-size: 0.875rem; // text-sm
font-weight: 500; // font-medium
color: $text-color-medium;
margin-bottom: 0.25rem; // mb-1
}
.login-form__input {
display: block;
width: 100%;
padding: 0.5rem 1rem; // px-4 py-2
border: 1px solid $border-color;
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
font-size: 0.875rem; // sm:text-sm
&:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: $primary-color; // focus:border-blue-500
box-shadow:
0 0 0 1px $primary-color,
0 0 0 3px rgba($primary-color, 0.5); // focus:ring-blue-500
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.login-form__message {
text-align: center;
font-size: 0.875rem; // text-sm
font-weight: 500; // font-medium
}
.login-form__message--success {
color: $success-color;
}
.login-form__message--error {
color: $error-color;
}
.login-form__button {
width: 100%;
display: flex;
justify-content: center;
padding: 0.75rem 1rem; // py-3 px-4
border: 1px solid transparent;
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
font-size: 1.125rem; // text-lg
font-weight: 600; // font-semibold
color: $bg-color-white;
background-color: $primary-color;
cursor: pointer;
transition:
background-color 0.3s ease-in-out,
box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out
&:hover {
background-color: color.adjust(
$primary-color,
$lightness: -5%
); // blue-700 equivalent
}
&:focus {
outline: none;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.5),
0 0 0 4px rgba($primary-color, 0.5); // focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.login-form__spinner {
animation: spin 1s linear infinite;
height: 1.25rem; // h-5
width: 1.25rem; // w-5
color: white;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* --- Page Container Block (.page-container) --- */
.page-container {
min-height: 100vh;
background-color: $bg-color-light;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: "Inter", sans-serif; // Assuming Inter font is used
padding: 1rem;
}
.page-container__content {
width: 100%;
max-width: 56rem; // max-w-4xl
background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
padding: 2rem; // p-8
text-align: center;
}
.page-container__title {
font-size: 2.25rem; // text-4xl
font-weight: 700; // font-bold
color: $text-color-dark;
margin-bottom: 1.5rem; // mb-6
}
.page-container__message--logged-in {
font-size: 1.25rem; // text-xl
color: $success-color;
margin-bottom: 1rem; // mb-4
}
.page-container__text {
color: $text-color-medium;
margin-bottom: 1.5rem; // mb-6
}
.page-container__button--logout {
padding: 0.75rem 1.5rem; // px-6 py-3
background-color: $error-color;
color: $bg-color-white;
font-weight: 600; // font-semibold
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md
transition: background-color 0.3s ease-in-out;
}

View File

@ -0,0 +1,157 @@
// components/ChangePasswordModal.tsx
import React, { useState, useEffect } from "react";
import { Typography, TextField, Button, Box } from "@mui/material";
import toast from "react-hot-toast";
interface Props {
open: boolean;
onClose: () => void;
onSubmit: (passwordData: {
currentPassword?: string;
newPassword: string;
}) => void;
isUserChange?: boolean;
}
export const ChangePassword: React.FC<Props> = ({
open,
onSubmit,
isUserChange = false,
}) => {
const [currentPassword, setCurrentPassword] = useState("");
const [password, setPassword] = useState<string>("");
const [confirm, setConfirm] = useState<string>("");
const [errors, setErrors] = useState<string[]>([]);
const [isValid, setIsValid] = useState(false);
// Validate password rules
useEffect(() => {
if (!password) {
setErrors([]);
setIsValid(false);
return;
}
const newErrors: string[] = [];
if (password.length < 8) newErrors.push("At least 8 characters");
if (!/[A-Z]/.test(password)) newErrors.push("One uppercase letter");
if (!/[a-z]/.test(password)) newErrors.push("One lowercase letter");
if (!/[0-9]/.test(password)) newErrors.push("One number");
if (!/[!@#$%^&*(),.?\":{}|<>]/.test(password))
newErrors.push("One special character");
if (password !== confirm) newErrors.push("Passwords do not match");
// If user change mode, require current password
if (isUserChange && !currentPassword)
newErrors.push("Current password required");
setErrors(newErrors);
setIsValid(newErrors.length === 0);
}, [confirm, password, currentPassword, isUserChange]);
const handleSubmit = () => {
if (!isValid) {
toast.error("Please fix the validation errors before continuing");
return;
}
onSubmit({
...(isUserChange ? { currentPassword } : {}),
newPassword: password,
});
};
return (
<Box
sx={{
backgroundColor: "white",
p: 3,
borderRadius: 2,
width: 400,
display: open ? "flex" : "none",
flexDirection: "column",
gap: 2,
boxShadow: 3,
}}
>
{isUserChange ? (
<Typography variant="body2" color="text.secondary">
Please enter your current password and choose a new one below.
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
Youre currently logged in with a temporary password. Please set a new
one to continue.
</Typography>
)}
{isUserChange && (
<TextField
label="Current Password"
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
fullWidth
error={isUserChange && !currentPassword}
helperText={
isUserChange && !currentPassword
? "Current password is required"
: ""
}
/>
)}
<TextField
label="New Password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
fullWidth
error={!isValid && password.length > 0}
/>
<TextField
label="Confirm Password"
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
fullWidth
error={confirm.length > 0 && password !== confirm}
/>
<Box sx={{ height: "80px" }}>
<Typography
variant="caption"
color={isValid ? "success.main" : "error.main"}
sx={{
fontWeight: 500,
visibility: password.length > 0 ? "visible" : "hidden",
}}
>
<p>
{isValid
? "✓ Password meets all requirements"
: "Password must contain:"}
</p>
</Typography>
{!isValid && (
<Typography
variant="caption"
sx={{ color: "error.main", fontSize: "0.8rem" }}
>
{errors.join(", ")}
</Typography>
)}
</Box>
<Button
variant="contained"
sx={{ width: "100%", height: 40, fontSize: 16 }}
onClick={handleSubmit}
disabled={!isValid}
>
Update Password
</Button>
</Box>
);
};

View File

@ -1,4 +1,5 @@
/* app/styles/LoginModal.scss (BEM Methodology) */ /* app/styles/LoginModal.scss (BEM Methodology) */
@use "sass:color";
// Variables for consistent styling // Variables for consistent styling
$primary-color: #2563eb; // Blue-600 equivalent $primary-color: #2563eb; // Blue-600 equivalent
@ -30,7 +31,8 @@ $bg-color-white: #ffffff;
.login-modal__content { .login-modal__content {
background-color: $bg-color-white; background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl border-radius: 0.75rem; // rounded-xl
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04); // shadow-2xl 0 10px 10px -5px rgba(0, 0, 0, 0.04); // shadow-2xl
padding: 2rem; // p-8 padding: 2rem; // p-8
width: 100%; width: 100%;
@ -79,7 +81,9 @@ $bg-color-white: #ffffff;
outline: 2px solid transparent; outline: 2px solid transparent;
outline-offset: 2px; outline-offset: 2px;
border-color: $primary-color; // focus:border-blue-500 border-color: $primary-color; // focus:border-blue-500
box-shadow: 0 0 0 1px $primary-color, 0 0 0 3px rgba($primary-color, 0.5); // focus:ring-blue-500 box-shadow:
0 0 0 1px $primary-color,
0 0 0 3px rgba($primary-color, 0.5); // focus:ring-blue-500
} }
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
@ -114,13 +118,19 @@ $bg-color-white: #ffffff;
color: $bg-color-white; color: $bg-color-white;
background-color: $primary-color; background-color: $primary-color;
cursor: pointer; cursor: pointer;
transition: background-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out transition:
background-color 0.3s ease-in-out,
box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out
&:hover { &:hover {
background-color: darken($primary-color, 5%); // blue-700 equivalent background-color: color.adjust(
$primary-color,
$lightness: -5%
); // blue-700 equivalent
} }
&:focus { &:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5), box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.5),
0 0 0 4px rgba($primary-color, 0.5); // focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 0 0 0 4px rgba($primary-color, 0.5); // focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
} }
&:disabled { &:disabled {
@ -162,7 +172,8 @@ $bg-color-white: #ffffff;
max-width: 56rem; // max-w-4xl max-width: 56rem; // max-w-4xl
background-color: $bg-color-white; background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl border-radius: 0.75rem; // rounded-xl
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg 0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
padding: 2rem; // p-8 padding: 2rem; // p-8
text-align: center; text-align: center;

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import "./LoginModal.scss"; // Adjust path based on your actual structure import "./LoginModal.scss"; // Adjust path based on your actual structure
import { clearAuthMessage } from "@/app/redux/auth/authSlice";
// Define the props interface for LoginModal // Define the props interface for LoginModal
type LoginModalProps = { type LoginModalProps = {
@ -19,12 +20,8 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
// Effect to clear authentication messages when email or password inputs change // Effect to clear authentication messages when email or password inputs change
useEffect(() => { useEffect(() => {
// clearAuthMessage(); clearAuthMessage();
}, [ }, [email, password]); // Dependency array ensures effect runs when these change
email,
password,
// clearAuthMessage
]); // Dependency array ensures effect runs when these change
// Handler for form submission // Handler for form submission
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@ -47,7 +44,7 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
className="login-form__input" className="login-form__input"
placeholder="admin@example.com" placeholder="admin@example.com"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
/> />
@ -64,7 +61,7 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
className="login-form__input" className="login-form__input"
placeholder="password123" placeholder="password123"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
/> />
@ -76,7 +73,6 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
disabled={isLoading} // Disable button while loading disabled={isLoading} // Disable button while loading
> >
{isLoading ? ( {isLoading ? (
// SVG spinner for loading state
<svg <svg
className="login-form__spinner" className="login-form__spinner"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@ -67,20 +67,20 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
const handleStatusSave = () => { const handleStatusSave = () => {
console.log( console.log(
`Status changed for row with ID ${selectedRowId}. New status: ${newStatus}. Reason: ${reason}`, `Status changed for row with ID ${selectedRowId}. New status: ${newStatus}. Reason: ${reason}`
); );
setRows( setRows(
rows.map((row) => rows.map(row =>
row.id === selectedRowId ? { ...row, status: newStatus } : row, row.id === selectedRowId ? { ...row, status: newStatus } : row
), )
); );
setModalOpen(false); setModalOpen(false);
setReason(""); setReason("");
}; };
const getColumnsWithDropdown = (columns: TColumn[]): GridColDef[] => { const getColumnsWithDropdown = (columns: TColumn[]): GridColDef[] => {
return columns.map((col) => { return columns.map(col => {
if (col.field === "status") { if (col.field === "status") {
return { return {
...col, ...col,
@ -130,53 +130,52 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
}; };
} }
if (col.field === "userId") { if (col.field === "userId") {
return { return {
...col, ...col,
headerAlign: "center", headerAlign: "center",
align: "center", align: "center",
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: "1fr auto", gridTemplateColumns: "1fr auto",
alignItems: "center", alignItems: "center",
width: "100%", width: "100%",
px: 1, px: 1,
}} }}
onClick={(e) => e.stopPropagation()} // keep row click from firing when clicking inside onClick={e => e.stopPropagation()} // keep row click from firing when clicking inside
> >
<Box <Box
sx={{ sx={{
fontWeight: 500, fontWeight: 500,
fontSize: "0.875rem", fontSize: "0.875rem",
color: "text.primary", color: "text.primary",
}} }}
> >
{params.value} {params.value}
</Box> </Box>
<IconButton
href={`/users/${params.value}`}
target="_blank"
rel="noopener noreferrer"
size="small"
sx={{ p: 0.5, ml: 1 }}
onClick={(e) => e.stopPropagation()}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Box>
),
};
}
<IconButton
href={`/users/${params.value}`}
target="_blank"
rel="noopener noreferrer"
size="small"
sx={{ p: 0.5, ml: 1 }}
onClick={e => e.stopPropagation()}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Box>
),
};
}
if (col.field === "actions") { if (col.field === "actions") {
return { return {
...col, ...col,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const row = tableRows.find((r) => r.id === params.id) as { const row = tableRows.find(r => r.id === params.id) as {
id: number; id: number;
status?: string; status?: string;
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
@ -188,7 +187,7 @@ if (col.field === "userId") {
return ( return (
<Select <Select
value={params.value ?? row.status} value={params.value ?? row.status}
onChange={(e) => onChange={e =>
handleStatusChange(params.id as number, e.target.value) handleStatusChange(params.id as number, e.target.value)
} }
size="small" size="small"
@ -197,9 +196,9 @@ if (col.field === "userId") {
"& .MuiOutlinedInput-notchedOutline": { border: "none" }, "& .MuiOutlinedInput-notchedOutline": { border: "none" },
"& .MuiSelect-select": { py: 0.5 }, "& .MuiSelect-select": { py: 0.5 },
}} }}
onClick={(e) => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{options.map((option) => ( {options.map(option => (
<MenuItem key={option.value} value={option.value}> <MenuItem key={option.value} value={option.value}>
{option.label} {option.label}
</MenuItem> </MenuItem>
@ -218,7 +217,7 @@ if (col.field === "userId") {
if (extraColumns && extraColumns.length > 0) { if (extraColumns && extraColumns.length > 0) {
filteredColumns = showExtraColumns filteredColumns = showExtraColumns
? tableColumns ? tableColumns
: tableColumns.filter((col) => !extraColumns.includes(col.field)); : tableColumns.filter(col => !extraColumns.includes(col.field));
} }
return ( return (
@ -235,7 +234,7 @@ if (col.field === "userId") {
label="Search" label="Search"
variant="outlined" variant="outlined"
size="small" size="small"
onChange={(e) => console.log(`setSearchQuery(${e.target.value})`)} onChange={e => console.log(`setSearchQuery(${e.target.value})`)}
sx={{ width: 300 }} sx={{ width: 300 }}
/> />
<AdvancedSearch labels={tableSearchLabels} /> <AdvancedSearch labels={tableSearchLabels} />
@ -250,7 +249,7 @@ if (col.field === "userId") {
{extraColumns && extraColumns.length > 0 && ( {extraColumns && extraColumns.length > 0 && (
<Button <Button
variant="outlined" variant="outlined"
onClick={() => setShowExtraColumns((prev) => !prev)} onClick={() => setShowExtraColumns(prev => !prev)}
> >
{showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"} {showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"}
</Button> </Button>
@ -281,7 +280,7 @@ if (col.field === "userId") {
justifyContent: "center", justifyContent: "center",
}, },
}} }}
onCellClick={(params) => { onCellClick={params => {
if (params.field !== "actions") { if (params.field !== "actions") {
handleClickField(params.field, params.value as string); handleClickField(params.field, params.value as string);
} }
@ -305,7 +304,7 @@ if (col.field === "userId") {
<FormControl fullWidth sx={{ mt: 2 }}> <FormControl fullWidth sx={{ mt: 2 }}>
<Select <Select
value={fileType} value={fileType}
onChange={(e) => onChange={e =>
setFileType(e.target.value as "csv" | "xls" | "xlsx") setFileType(e.target.value as "csv" | "xls" | "xlsx")
} }
> >
@ -318,7 +317,7 @@ if (col.field === "userId") {
control={ control={
<Checkbox <Checkbox
checked={onlyCurrentTable} checked={onlyCurrentTable}
onChange={(e) => setOnlyCurrentTable(e.target.checked)} onChange={e => setOnlyCurrentTable(e.target.checked)}
/> />
} }
label="Only export current table" label="Only export current table"
@ -335,7 +334,7 @@ if (col.field === "userId") {
tableColumns, tableColumns,
fileType, fileType,
onlyCurrentTable, onlyCurrentTable,
setOpen, setOpen
) )
} }
> >

View File

@ -46,7 +46,7 @@ const StatusChangeDialog = ({
multiline multiline
rows={4} rows={4}
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={e => setReason(e.target.value)}
helperText="Reason must be between 12 and 400 characters" helperText="Reason must be between 12 and 400 characters"
sx={{ mt: 2 }} sx={{ mt: 2 }}
/> />

View File

@ -1,12 +1,12 @@
.date-range-picker { .date-range-picker {
.date-range-picker__date-typo { .date-range-picker__date-typo {
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 4px;
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.04); background-color: rgba(0, 0, 0, 0.04);
}
} }
}
} }

View File

@ -19,7 +19,7 @@ export const DateRangePicker = () => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const handleSelect: DateRangeProps["onChange"] = (ranges) => { const handleSelect: DateRangeProps["onChange"] = ranges => {
if (ranges.selection) { if (ranges.selection) {
setRange([ranges.selection]); setRange([ranges.selection]);
if (ranges.selection.endDate !== ranges.selection.startDate) { if (ranges.selection.endDate !== ranges.selection.startDate) {

View File

@ -1,5 +1,5 @@
.documentation { .documentation {
.documentation__icon { .documentation__icon {
height: auto; height: auto;
} }
} }

View File

@ -1,6 +1,6 @@
.fetch-report { .fetch-report {
padding: 23px; padding: 23px;
margin: 16px; margin: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }

View File

@ -52,7 +52,7 @@ export const FetchReport = () => {
<InputLabel>Select state (defaults to All)</InputLabel> <InputLabel>Select state (defaults to All)</InputLabel>
<Select <Select
value={state} value={state}
onChange={(e) => setState(e.target.value)} onChange={e => setState(e.target.value)}
label="Select state (defaults to All)" label="Select state (defaults to All)"
> >
<MenuItem value="successful">Successful</MenuItem> <MenuItem value="successful">Successful</MenuItem>
@ -67,7 +67,7 @@ export const FetchReport = () => {
<InputLabel>Select PSPs (defaults to All)</InputLabel> <InputLabel>Select PSPs (defaults to All)</InputLabel>
<Select <Select
value={psp} value={psp}
onChange={(e) => setPsp(e.target.value)} onChange={e => setPsp(e.target.value)}
label="Select PSPs (defaults to All)" label="Select PSPs (defaults to All)"
> >
<MenuItem value="a1">A1</MenuItem> <MenuItem value="a1">A1</MenuItem>
@ -139,7 +139,7 @@ export const FetchReport = () => {
<InputLabel>Select report type</InputLabel> <InputLabel>Select report type</InputLabel>
<Select <Select
value={reportType} value={reportType}
onChange={(e) => setReportType(e.target.value)} onChange={e => setReportType(e.target.value)}
label="Select report type" label="Select report type"
> >
<MenuItem value="allTransactionsReport"> <MenuItem value="allTransactionsReport">

View File

@ -1,18 +1,18 @@
.general-health-card { .general-health-card {
.general-health-card__header { .general-health-card__header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
.general-health-card__right-side { .general-health-card__right-side {
display: flex; display: flex;
align-items: center; align-items: center;
}
} }
}
.general-health-card__stat-items { .general-health-card__stat-items {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
margin-top: 16px; margin-top: 16px;
} }
} }

View File

@ -1,12 +1,12 @@
.static-item { .static-item {
text-align: center; text-align: center;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
.static-item__percentage { .static-item__percentage {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: red; color: red;
} }
} }

View File

@ -92,7 +92,7 @@ export function ApproveTable<T extends { id: string | number }>({
router.replace(`?${params.toString()}`, { scroll: false }); router.replace(`?${params.toString()}`, { scroll: false });
}, 400), }, 400),
[router, searchParams, searchParamKey], [router, searchParams, searchParamKey]
); );
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -102,13 +102,11 @@ export function ApproveTable<T extends { id: string | number }>({
}; };
const handleCheckboxChange = (id: string | number, checked: boolean) => { const handleCheckboxChange = (id: string | number, checked: boolean) => {
setSelected((prev) => setSelected(prev => (checked ? [...prev, id] : prev.filter(x => x !== id)));
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
}; };
const handleToggleAll = (checked: boolean) => { const handleToggleAll = (checked: boolean) => {
setSelected(checked ? rows.map((r) => r.id) : []); setSelected(checked ? rows.map(r => r.id) : []);
}; };
const handleActionChange = (e: SelectChangeEvent<string>) => { const handleActionChange = (e: SelectChangeEvent<string>) => {
@ -125,7 +123,7 @@ export function ApproveTable<T extends { id: string | number }>({
const handleStatusSave = () => { const handleStatusSave = () => {
console.log( console.log(
`Status changed for row with ID ${selected}. New status: ${action}. Reason: ${reason}`, `Status changed for row with ID ${selected}. New status: ${action}. Reason: ${reason}`
); );
setModalOpen(false); setModalOpen(false);
setReason(""); setReason("");
@ -177,7 +175,7 @@ export function ApproveTable<T extends { id: string | number }>({
onChange={handleActionChange} onChange={handleActionChange}
size="small" size="small"
> >
{actions.map((item) => ( {actions.map(item => (
<MenuItem key={item.value} value={item.value}> <MenuItem key={item.value} value={item.value}>
{item.label ?? item.value} {item.label ?? item.value}
</MenuItem> </MenuItem>
@ -219,7 +217,7 @@ export function ApproveTable<T extends { id: string | number }>({
indeterminate={ indeterminate={
selected.length > 0 && selected.length < rows.length selected.length > 0 && selected.length < rows.length
} }
onChange={(e) => handleToggleAll(e.target.checked)} onChange={e => handleToggleAll(e.target.checked)}
/> />
</TableCell> </TableCell>
@ -257,7 +255,7 @@ export function ApproveTable<T extends { id: string | number }>({
> >
<Checkbox <Checkbox
checked={selected.includes(row.id)} checked={selected.includes(row.id)}
onChange={(e) => onChange={e =>
handleCheckboxChange(row.id, e.target.checked) handleCheckboxChange(row.id, e.target.checked)
} }
/> />
@ -304,7 +302,9 @@ export function ApproveTable<T extends { id: string | number }>({
width: "100%", width: "100%",
}} }}
> >
<Box sx={{textAlign: "center"}}>{value as React.ReactNode}</Box> <Box sx={{ textAlign: "center" }}>
{value as React.ReactNode}
</Box>
<IconButton <IconButton
size="small" size="small"

View File

@ -0,0 +1,57 @@
"use client";
import React, { useState } from "react";
import { Paper, Typography } from "@mui/material";
import { ChangePassword } from "@/app/features/Auth/ChangePassword/ChangePassword";
import { RootState } from "@/app/redux/store";
import { useSelector, useDispatch } from "react-redux";
import { AppDispatch } from "@/app/redux/types";
import { changePassword } from "@/app/redux/auth/authSlice";
const SettingsAccountSecurity: React.FC = () => {
const [submitting, setSubmitting] = useState(false);
const user = useSelector((state: RootState) => state.auth.user);
const dispatch = useDispatch<AppDispatch>();
const handleChangePassword = async (passwordData: {
currentPassword?: string;
newPassword: string;
}) => {
try {
setSubmitting(true);
if (!user?.email) {
throw new Error("User email not available");
}
await dispatch(
changePassword({
email: user.email,
newPassword: passwordData.newPassword,
currentPassword: passwordData.currentPassword,
})
);
} catch (err: any) {
// Error handling is now done by the epic
console.error("Password change error:", err);
} finally {
setSubmitting(false);
}
};
return (
<Paper elevation={1} sx={{ p: 3, maxWidth: 520 }}>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Account Security
</Typography>
<ChangePassword
isUserChange={true}
open={!submitting}
onClose={() => {}}
onSubmit={handleChangePassword}
/>
</Paper>
);
};
export default SettingsAccountSecurity;

View File

@ -0,0 +1,39 @@
"use client";
import React, { useState } from "react";
import { Box, Typography } from "@mui/material";
import SettingsAccountSecurity from "./SettingsAccountSecurity";
import SettingsPersonalInfo from "./SettingsPersonalInfo";
import SettingsSidebar from "./SettingsSidebar";
const SettingsPageClient: React.FC = () => {
const [activeSection, setActiveSection] = useState<"personal" | "account">(
"personal"
);
return (
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
<Typography variant="h5" fontWeight={600}>
Settings
</Typography>
<Box sx={{ display: "flex", gap: 3 }}>
<SettingsSidebar
active={activeSection}
onChange={(
section:
| string
| ((prevState: "personal" | "account") => "personal" | "account")
) => setActiveSection(section)}
/>
<Box sx={{ flex: 1 }}>
{activeSection === "personal" && <SettingsPersonalInfo />}
{activeSection === "account" && <SettingsAccountSecurity />}
</Box>
</Box>
</Box>
);
};
export default SettingsPageClient;

View File

@ -0,0 +1,105 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Paper,
Typography,
Box,
TextField,
Divider,
Button,
} from "@mui/material";
import { useSelector, useDispatch } from "react-redux";
import { AppDispatch, RootState } from "@/app/redux/store";
import { updateUserDetails } from "@/app/redux/auth/authSlice";
const SettingsPersonalInfo: React.FC = () => {
const user = useSelector((state: RootState) => state.auth.user);
const dispatch = useDispatch<AppDispatch>();
const [formData, setFormData] = useState({
first_name: "",
last_name: "",
username: "",
email: "",
});
// Initialize form fields from Redux user data
useEffect(() => {
if (user) {
setFormData({
first_name: user.firstName ?? "",
last_name: user.lastName ?? "",
username: user.username ?? "",
email: user.email ?? "",
});
}
}, [user]);
// Generic change handler
const handleChange =
(field: keyof typeof formData) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
handleUpdateUserDetails();
};
const handleUpdateUserDetails = () => {
dispatch(updateUserDetails({ id: user?.id ?? "", updates: formData }));
};
return (
<Paper elevation={1} sx={{ p: 3, maxWidth: 720 }}>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Personal Info
</Typography>
<Box
component="form"
onSubmit={handleSave}
sx={{ display: "flex", flexDirection: "column", gap: 2 }}
>
<TextField
label="First Name"
value={formData.first_name}
onChange={handleChange("first_name")}
fullWidth
/>
<TextField
label="Last Name"
value={formData.last_name}
onChange={handleChange("last_name")}
fullWidth
/>
<TextField
label="Username"
value={formData.username}
onChange={handleChange("username")}
fullWidth
/>
<TextField
disabled={true}
label="Email"
type="email"
value={formData.email}
onChange={handleChange("email")}
fullWidth
/>
<Divider sx={{ my: 1 }} />
<Button
type="submit"
variant="contained"
sx={{ alignSelf: "flex-start" }}
>
Save Changes
</Button>
</Box>
</Paper>
);
};
export default SettingsPersonalInfo;

View File

@ -0,0 +1,46 @@
"use client";
import React from "react";
import {
Paper,
List,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import SecurityIcon from "@mui/icons-material/Security";
interface Props {
active: "personal" | "account";
onChange: (section: "personal" | "account") => void;
}
const SettingsSidebar: React.FC<Props> = ({ active, onChange }) => {
return (
<Paper elevation={1} sx={{ width: 260, p: 1 }}>
<List component="nav">
<ListItemButton
selected={active === "personal"}
onClick={() => onChange("personal")}
>
<ListItemIcon>
<AccountCircleIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Personal Info" />
</ListItemButton>
<ListItemButton
selected={active === "account"}
onClick={() => onChange("account")}
>
<ListItemIcon>
<SecurityIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Account Security" />
</ListItemButton>
</List>
</Paper>
);
};
export default SettingsSidebar;

View File

@ -1,8 +1,8 @@
.pie-charts { .pie-charts {
width: 100%; width: 100%;
height: 300px; height: 300px;
@media (min-width: 960px) { @media (min-width: 960px) {
width: 60%; width: 60%;
} }
} }

View File

@ -1,20 +1,20 @@
.section-card { .section-card {
padding: 16px; padding: 16px;
margin: 16px; margin: 16px;
display: flex;
flex-direction: column;
.section-card__header {
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
.section-card__header { margin-bottom: 16px;
display: flex; .section-card__title {
justify-content: space-between; display: flex;
margin-bottom: 16px; gap: 16px;
.section-card__title {
display: flex;
gap: 16px;
}
.section-card__icon-wrapper {
display: flex;
align-items: center;
gap: 16px;
}
} }
.section-card__icon-wrapper {
display: flex;
align-items: center;
gap: 16px;
}
}
} }

View File

@ -1,28 +1,28 @@
.transaction-overview { .transaction-overview {
padding: 23px; padding: 23px;
margin: 16px;
display: flex;
flex-direction: column;
.transaction-overview__header {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 8px;
padding-right: 8px;
}
.transaction-overview__chart-table {
padding: 16px;
margin: 16px; margin: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 32px;
flex-wrap: wrap;
.transaction-overview__header { @media (min-width: 960px) {
display: flex; flex-wrap: nowrap;
justify-content: space-between; gap: 0;
align-items: center;
padding-left: 8px;
padding-right: 8px;
}
.transaction-overview__chart-table {
padding: 16px;
margin: 16px;
display: flex;
flex-direction: row;
gap: 32px;
flex-wrap: wrap;
@media (min-width: 960px) {
flex-wrap: nowrap;
gap: 0;
}
} }
}
} }

View File

@ -1,17 +1,17 @@
.transactions-overview-table { .transactions-overview-table {
.transactions-overview-table__state-wrapper { .transactions-overview-table__state-wrapper {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
width: 73px; width: 73px;
.transactions-overview-table__state { .transactions-overview-table__state {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
margin-right: 8px; margin-right: 8px;
}
} }
}
} }

View File

@ -1,6 +1,6 @@
.transactions-waiting-approval { .transactions-waiting-approval {
padding: 16px; padding: 16px;
margin: 16px; margin: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }

View File

@ -81,3 +81,107 @@
} }
} }
} }
.phone-input-container {
flex: 1 1 100%;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 4px;
.country-code-select {
padding: 8px;
font-size: 0.9rem;
border-radius: 4px;
border: 1px solid #ccc;
outline: none;
transition: border-color 0.3s ease;
background: #f8f9fa;
cursor: pointer;
&:focus {
border-color: #0070f3;
}
}
input[type="tel"] {
flex: 1;
padding: 8px;
font-size: 1rem;
border-radius: 4px;
border: 1px solid #ccc;
outline: none;
transition: border-color 0.3s ease;
&:focus {
border-color: #0070f3;
}
&.phone-input-error {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
}
.phone-error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 2px;
padding-left: 4px;
}
}
.array-field-container {
flex: 1 1 100%;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 8px;
label {
font-weight: 500;
color: #333;
font-size: 0.9rem;
}
.selected-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.selected-item {
display: inline-flex;
align-items: center;
gap: 4px;
background: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 16px;
font-size: 0.875rem;
border: 1px solid #bbdefb;
.remove-item {
background: none;
border: none;
color: #1976d2;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0;
margin-left: 4px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(25, 118, 210, 0.1);
}
}
}
}

View File

@ -5,6 +5,8 @@ import { useRouter } from "next/navigation";
import "./AddUser.scss"; import "./AddUser.scss";
import { addUser } from "@/services/roles.services"; import { addUser } from "@/services/roles.services";
import { IEditUserForm } from "../User.interfaces"; import { IEditUserForm } from "../User.interfaces";
import { COUNTRY_CODES } from "../constants";
import { formatPhoneDisplay, validatePhone } from "../utils";
interface AddUserFormProps { interface AddUserFormProps {
onSuccess?: () => void; onSuccess?: () => void;
@ -13,27 +15,81 @@ interface AddUserFormProps {
const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => { const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState<IEditUserForm>({ const [form, setForm] = useState<IEditUserForm>({
username: "",
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
role: "", role: "",
merchants: [],
groups: [],
jobTitle: "",
}); });
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [phoneError, setPhoneError] = useState("");
const [countryCode, setCountryCode] = useState("+1");
const handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => { ) => {
const { name, value } = e.target; const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
if (name === "countryCode") {
setCountryCode(value);
return;
}
// Handle array fields (merchants and groups)
if (name === "merchants" || name === "groups") {
if (value === "") {
// If empty selection, set empty array
setForm(prev => ({ ...prev, [name]: [] }));
} else {
// Add the selected value to the array if not already present
setForm(prev => {
const currentArray = prev[name as keyof IEditUserForm] as string[];
if (!currentArray.includes(value)) {
return { ...prev, [name]: [...currentArray, value] };
}
return prev;
});
}
} else {
// Handle single value fields
setForm(prev => ({ ...prev, [name]: value }));
}
};
const handleCountryCodeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newCountryCode = e.target.value;
setCountryCode(newCountryCode);
// Re-validate phone if it exists
if (form.phone) {
const phoneError = validatePhone(form.phone, newCountryCode);
setPhoneError(phoneError);
}
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!form.firstName || !form.lastName || !form.email || !form.role) { // Validate phone number if provided
if (form.phone && phoneError) {
setError("Please fix phone number errors before submitting.");
return;
}
if (
!form.firstName ||
!form.lastName ||
!form.email ||
form.merchants.length === 0 ||
form.groups.length === 0 ||
!form.jobTitle
) {
setError("Please fill in all required fields."); setError("Please fill in all required fields.");
return; return;
} }
@ -42,7 +98,13 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
setLoading(true); setLoading(true);
setError(""); setError("");
await addUser(form); // Format phone number with country code before submission
const formattedForm = {
...form,
phone: form.phone ? formatPhoneDisplay(form.phone, countryCode) : "",
};
await addUser(formattedForm);
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();
router.refresh(); // <- refreshes the page (SSR re-runs) router.refresh(); // <- refreshes the page (SSR re-runs)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -55,6 +117,13 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
return ( return (
<form className="add-user" onSubmit={handleSubmit}> <form className="add-user" onSubmit={handleSubmit}>
<input
name="username"
placeholder="Username"
value={form.username}
onChange={handleChange}
required
/>
<input <input
name="firstName" name="firstName"
placeholder="First Name" placeholder="First Name"
@ -77,26 +146,126 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
onChange={handleChange} onChange={handleChange}
required required
/> />
<input <div className="array-field-container">
name="phone" <label>Merchants:</label>
placeholder="Phone (optional)" <select
value={form.phone} name="merchants"
onChange={handleChange} value=""
/> onChange={handleChange}
className="add-user__select"
>
<option value="">Select Merchant</option>
<option value="Win Bot">Win Bot</option>
<option value="Data Spin">Data Spin</option>
</select>
{form.merchants.length > 0 && (
<div className="selected-items">
{form.merchants.map((merchant, index) => (
<span key={index} className="selected-item">
{merchant}
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
merchants: prev.merchants.filter((_, i) => i !== index),
}));
}}
className="remove-item"
>
×
</button>
</span>
))}
</div>
)}
</div>
<div className="array-field-container">
<label>Groups:</label>
<select
name="groups"
value=""
onChange={handleChange}
className="add-user__select"
>
<option value="">Select Group</option>
<option value="Admin">Admin</option>
<option value="Reader">Reader</option>
<option value="Manager">Manager</option>
<option value="User">User</option>
</select>
{form.groups.length > 0 && (
<div className="selected-items">
{form.groups.map((group, index) => (
<span key={index} className="selected-item">
{group}
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
groups: prev.groups.filter((_, i) => i !== index),
}));
}}
className="remove-item"
>
×
</button>
</span>
))}
</div>
)}
</div>
<select <select
name="role" name="jobTitle"
value={form.role} value={form.jobTitle}
onChange={handleChange} onChange={handleChange}
required required
className="add-user__select" className="add-user__select"
> >
<option value="">Select Role</option> <option value="">Select Job Title</option>
<option value="ROLE_IIN">ROLE_IIN</option> <option value="Admin">Admin</option>
<option value="ROLE_RULES_ADMIN">ROLE_RULES_ADMIN</option> <option value="Reader">Reader</option>
<option value="ROLE_FIRST_APPROVER">ROLE_FIRST_APPROVER</option> <option value="User">User</option>
<option value="Manager">Manager</option>
<option value="Supervisor">Supervisor</option>
<option value="Director">Director</option>
</select> </select>
<div className="phone-input-container">
<select
name="countryCode"
value={countryCode}
onChange={handleCountryCodeChange}
className="country-code-select"
>
{COUNTRY_CODES.map(country => (
<option key={country.code} value={country.code}>
{country.flag} {country.code} {country.country}
</option>
))}
</select>
<input
name="phone"
type="tel"
placeholder="Phone number (optional)"
value={form.phone}
onChange={handleChange}
className={phoneError ? "phone-input-error" : ""}
/>
</div>
{error && <div style={{ color: "red", width: "100%" }}>{error}</div>} {error && <span style={{ color: "red", width: "100%" }}>{error}</span>}
<span
className="phone-error-message"
style={{
visibility: phoneError ? "visible" : "hidden",
color: "red",
width: "100%",
}}
>
{phoneError}
</span>
<div className="add-user__button-container"> <div className="add-user__button-container">
<button type="submit" disabled={loading}> <button type="submit" disabled={loading}>

View File

@ -21,12 +21,12 @@ const EditUser = ({ user }: { user: IUser }) => {
const value = e.target.value; const value = e.target.value;
if (name === "phone") { if (name === "phone") {
const filtered = value.replace(/[^0-9+\-\s()]/g, ""); const filtered = value.replace(/[^0-9+\-\s()]/g, "");
setForm((prev) => ({ setForm(prev => ({
...prev, ...prev,
phone: filtered, phone: filtered,
})); }));
} else { } else {
setForm((prev) => ({ setForm(prev => ({
...prev, ...prev,
[name]: value, [name]: value,
})); }));

View File

@ -1,12 +1,20 @@
export interface IEditUserForm { export interface IEditUserForm {
username: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; email: string;
role: string; role: string;
phone: string; phone: string;
merchants: string[];
groups: string[];
jobTitle: string;
} }
export type EditUserField = export type EditUserField =
| "merchants"
| "groups"
| "jobTitle"
| "username"
| "firstName" | "firstName"
| "lastName" | "lastName"
| "email" | "email"

View File

@ -0,0 +1,217 @@
// Country codes for phone prefixes
export const COUNTRY_CODES = [
{ code: "+1", country: "US/Canada", flag: "🇺🇸" },
{ code: "+44", country: "UK", flag: "🇬🇧" },
{ code: "+49", country: "Germany", flag: "🇩🇪" },
{ code: "+33", country: "France", flag: "🇫🇷" },
{ code: "+39", country: "Italy", flag: "🇮🇹" },
{ code: "+34", country: "Spain", flag: "🇪🇸" },
{ code: "+31", country: "Netherlands", flag: "🇳🇱" },
{ code: "+32", country: "Belgium", flag: "🇧🇪" },
{ code: "+41", country: "Switzerland", flag: "🇨🇭" },
{ code: "+43", country: "Austria", flag: "🇦🇹" },
{ code: "+45", country: "Denmark", flag: "🇩🇰" },
{ code: "+46", country: "Sweden", flag: "🇸🇪" },
{ code: "+47", country: "Norway", flag: "🇳🇴" },
{ code: "+358", country: "Finland", flag: "🇫🇮" },
{ code: "+48", country: "Poland", flag: "🇵🇱" },
{ code: "+420", country: "Czech Republic", flag: "🇨🇿" },
{ code: "+36", country: "Hungary", flag: "🇭🇺" },
{ code: "+40", country: "Romania", flag: "🇷🇴" },
{ code: "+359", country: "Bulgaria", flag: "🇧🇬" },
{ code: "+385", country: "Croatia", flag: "🇭🇷" },
{ code: "+386", country: "Slovenia", flag: "🇸🇮" },
{ code: "+421", country: "Slovakia", flag: "🇸🇰" },
{ code: "+370", country: "Lithuania", flag: "🇱🇹" },
{ code: "+371", country: "Latvia", flag: "🇱🇻" },
{ code: "+372", country: "Estonia", flag: "🇪🇪" },
{ code: "+353", country: "Ireland", flag: "🇮🇪" },
{ code: "+351", country: "Portugal", flag: "🇵🇹" },
{ code: "+30", country: "Greece", flag: "🇬🇷" },
{ code: "+357", country: "Cyprus", flag: "🇨🇾" },
{ code: "+356", country: "Malta", flag: "🇲🇹" },
{ code: "+352", country: "Luxembourg", flag: "🇱🇺" },
{ code: "+7", country: "Russia", flag: "🇷🇺" },
{ code: "+380", country: "Ukraine", flag: "🇺🇦" },
{ code: "+375", country: "Belarus", flag: "🇧🇾" },
{ code: "+370", country: "Lithuania", flag: "🇱🇹" },
{ code: "+371", country: "Latvia", flag: "🇱🇻" },
{ code: "+372", country: "Estonia", flag: "🇪🇪" },
{ code: "+81", country: "Japan", flag: "🇯🇵" },
{ code: "+82", country: "South Korea", flag: "🇰🇷" },
{ code: "+86", country: "China", flag: "🇨🇳" },
{ code: "+91", country: "India", flag: "🇮🇳" },
{ code: "+61", country: "Australia", flag: "🇦🇺" },
{ code: "+64", country: "New Zealand", flag: "🇳🇿" },
{ code: "+55", country: "Brazil", flag: "🇧🇷" },
{ code: "+52", country: "Mexico", flag: "🇲🇽" },
{ code: "+54", country: "Argentina", flag: "🇦🇷" },
{ code: "+56", country: "Chile", flag: "🇨🇱" },
{ code: "+57", country: "Colombia", flag: "🇨🇴" },
{ code: "+51", country: "Peru", flag: "🇵🇪" },
{ code: "+58", country: "Venezuela", flag: "🇻🇪" },
{ code: "+27", country: "South Africa", flag: "🇿🇦" },
{ code: "+20", country: "Egypt", flag: "🇪🇬" },
{ code: "+234", country: "Nigeria", flag: "🇳🇬" },
{ code: "+254", country: "Kenya", flag: "🇰🇪" },
{ code: "+233", country: "Ghana", flag: "🇬🇭" },
{ code: "+212", country: "Morocco", flag: "🇲🇦" },
{ code: "+213", country: "Algeria", flag: "🇩🇿" },
{ code: "+216", country: "Tunisia", flag: "🇹🇳" },
{ code: "+218", country: "Libya", flag: "🇱🇾" },
{ code: "+220", country: "Gambia", flag: "🇬🇲" },
{ code: "+221", country: "Senegal", flag: "🇸🇳" },
{ code: "+222", country: "Mauritania", flag: "🇲🇷" },
{ code: "+223", country: "Mali", flag: "🇲🇱" },
{ code: "+224", country: "Guinea", flag: "🇬🇳" },
{ code: "+225", country: "Ivory Coast", flag: "🇨🇮" },
{ code: "+226", country: "Burkina Faso", flag: "🇧🇫" },
{ code: "+227", country: "Niger", flag: "🇳🇪" },
{ code: "+228", country: "Togo", flag: "🇹🇬" },
{ code: "+229", country: "Benin", flag: "🇧🇯" },
{ code: "+230", country: "Mauritius", flag: "🇲🇺" },
{ code: "+231", country: "Liberia", flag: "🇱🇷" },
{ code: "+232", country: "Sierra Leone", flag: "🇸🇱" },
{ code: "+235", country: "Chad", flag: "🇹🇩" },
{ code: "+236", country: "Central African Republic", flag: "🇨🇫" },
{ code: "+237", country: "Cameroon", flag: "🇨🇲" },
{ code: "+238", country: "Cape Verde", flag: "🇨🇻" },
{ code: "+239", country: "São Tomé and Príncipe", flag: "🇸🇹" },
{ code: "+240", country: "Equatorial Guinea", flag: "🇬🇶" },
{ code: "+241", country: "Gabon", flag: "🇬🇦" },
{ code: "+242", country: "Republic of the Congo", flag: "🇨🇬" },
{ code: "+243", country: "Democratic Republic of the Congo", flag: "🇨🇩" },
{ code: "+244", country: "Angola", flag: "🇦🇴" },
{ code: "+245", country: "Guinea-Bissau", flag: "🇬🇼" },
{ code: "+246", country: "British Indian Ocean Territory", flag: "🇮🇴" },
{ code: "+248", country: "Seychelles", flag: "🇸🇨" },
{ code: "+249", country: "Sudan", flag: "🇸🇩" },
{ code: "+250", country: "Rwanda", flag: "🇷🇼" },
{ code: "+251", country: "Ethiopia", flag: "🇪🇹" },
{ code: "+252", country: "Somalia", flag: "🇸🇴" },
{ code: "+253", country: "Djibouti", flag: "🇩🇯" },
{ code: "+255", country: "Tanzania", flag: "🇹🇿" },
{ code: "+256", country: "Uganda", flag: "🇺🇬" },
{ code: "+257", country: "Burundi", flag: "🇧🇮" },
{ code: "+258", country: "Mozambique", flag: "🇲🇿" },
{ code: "+260", country: "Zambia", flag: "🇿🇲" },
{ code: "+261", country: "Madagascar", flag: "🇲🇬" },
{ code: "+262", country: "Réunion", flag: "🇷🇪" },
{ code: "+263", country: "Zimbabwe", flag: "🇿🇼" },
{ code: "+264", country: "Namibia", flag: "🇳🇦" },
{ code: "+265", country: "Malawi", flag: "🇲🇼" },
{ code: "+266", country: "Lesotho", flag: "🇱🇸" },
{ code: "+267", country: "Botswana", flag: "🇧🇼" },
{ code: "+268", country: "Swaziland", flag: "🇸🇿" },
{ code: "+269", country: "Comoros", flag: "🇰🇲" },
{ code: "+290", country: "Saint Helena", flag: "🇸🇭" },
{ code: "+291", country: "Eritrea", flag: "🇪🇷" },
{ code: "+297", country: "Aruba", flag: "🇦🇼" },
{ code: "+298", country: "Faroe Islands", flag: "🇫🇴" },
{ code: "+299", country: "Greenland", flag: "🇬🇱" },
{ code: "+350", country: "Gibraltar", flag: "🇬🇮" },
{ code: "+351", country: "Portugal", flag: "🇵🇹" },
{ code: "+352", country: "Luxembourg", flag: "🇱🇺" },
{ code: "+353", country: "Ireland", flag: "🇮🇪" },
{ code: "+354", country: "Iceland", flag: "🇮🇸" },
{ code: "+355", country: "Albania", flag: "🇦🇱" },
{ code: "+356", country: "Malta", flag: "🇲🇹" },
{ code: "+357", country: "Cyprus", flag: "🇨🇾" },
{ code: "+358", country: "Finland", flag: "🇫🇮" },
{ code: "+359", country: "Bulgaria", flag: "🇧🇬" },
{ code: "+370", country: "Lithuania", flag: "🇱🇹" },
{ code: "+371", country: "Latvia", flag: "🇱🇻" },
{ code: "+372", country: "Estonia", flag: "🇪🇪" },
{ code: "+373", country: "Moldova", flag: "🇲🇩" },
{ code: "+374", country: "Armenia", flag: "🇦🇲" },
{ code: "+375", country: "Belarus", flag: "🇧🇾" },
{ code: "+376", country: "Andorra", flag: "🇦🇩" },
{ code: "+377", country: "Monaco", flag: "🇲🇨" },
{ code: "+378", country: "San Marino", flag: "🇸🇲" },
{ code: "+380", country: "Ukraine", flag: "🇺🇦" },
{ code: "+381", country: "Serbia", flag: "🇷🇸" },
{ code: "+382", country: "Montenegro", flag: "🇲🇪" },
{ code: "+383", country: "Kosovo", flag: "🇽🇰" },
{ code: "+385", country: "Croatia", flag: "🇭🇷" },
{ code: "+386", country: "Slovenia", flag: "🇸🇮" },
{ code: "+387", country: "Bosnia and Herzegovina", flag: "🇧🇦" },
{ code: "+389", country: "North Macedonia", flag: "🇲🇰" },
{ code: "+420", country: "Czech Republic", flag: "🇨🇿" },
{ code: "+421", country: "Slovakia", flag: "🇸🇰" },
{ code: "+423", country: "Liechtenstein", flag: "🇱🇮" },
{ code: "+500", country: "Falkland Islands", flag: "🇫🇰" },
{ code: "+501", country: "Belize", flag: "🇧🇿" },
{ code: "+502", country: "Guatemala", flag: "🇬🇹" },
{ code: "+503", country: "El Salvador", flag: "🇸🇻" },
{ code: "+504", country: "Honduras", flag: "🇭🇳" },
{ code: "+505", country: "Nicaragua", flag: "🇳🇮" },
{ code: "+506", country: "Costa Rica", flag: "🇨🇷" },
{ code: "+507", country: "Panama", flag: "🇵🇦" },
{ code: "+508", country: "Saint Pierre and Miquelon", flag: "🇵🇲" },
{ code: "+509", country: "Haiti", flag: "🇭🇹" },
{ code: "+590", country: "Guadeloupe", flag: "🇬🇵" },
{ code: "+591", country: "Bolivia", flag: "🇧🇴" },
{ code: "+592", country: "Guyana", flag: "🇬🇾" },
{ code: "+593", country: "Ecuador", flag: "🇪🇨" },
{ code: "+594", country: "French Guiana", flag: "🇬🇫" },
{ code: "+595", country: "Paraguay", flag: "🇵🇾" },
{ code: "+596", country: "Martinique", flag: "🇲🇶" },
{ code: "+597", country: "Suriname", flag: "🇸🇷" },
{ code: "+598", country: "Uruguay", flag: "🇺🇾" },
{ code: "+599", country: "Netherlands Antilles", flag: "🇧🇶" },
{ code: "+670", country: "East Timor", flag: "🇹🇱" },
{ code: "+672", country: "Australian External Territories", flag: "🇦🇶" },
{ code: "+673", country: "Brunei", flag: "🇧🇳" },
{ code: "+674", country: "Nauru", flag: "🇳🇷" },
{ code: "+675", country: "Papua New Guinea", flag: "🇵🇬" },
{ code: "+676", country: "Tonga", flag: "🇹🇴" },
{ code: "+677", country: "Solomon Islands", flag: "🇸🇧" },
{ code: "+678", country: "Vanuatu", flag: "🇻🇺" },
{ code: "+679", country: "Fiji", flag: "🇫🇯" },
{ code: "+680", country: "Palau", flag: "🇵🇼" },
{ code: "+681", country: "Wallis and Futuna", flag: "🇼🇫" },
{ code: "+682", country: "Cook Islands", flag: "🇨🇰" },
{ code: "+683", country: "Niue", flag: "🇳🇺" },
{ code: "+684", country: "American Samoa", flag: "🇦🇸" },
{ code: "+685", country: "Samoa", flag: "🇼🇸" },
{ code: "+686", country: "Kiribati", flag: "🇰🇮" },
{ code: "+687", country: "New Caledonia", flag: "🇳🇨" },
{ code: "+688", country: "Tuvalu", flag: "🇹🇻" },
{ code: "+689", country: "French Polynesia", flag: "🇵🇫" },
{ code: "+690", country: "Tokelau", flag: "🇹🇰" },
{ code: "+691", country: "Micronesia", flag: "🇫🇲" },
{ code: "+692", country: "Marshall Islands", flag: "🇲🇭" },
{ code: "+850", country: "North Korea", flag: "🇰🇵" },
{ code: "+852", country: "Hong Kong", flag: "🇭🇰" },
{ code: "+853", country: "Macau", flag: "🇲🇴" },
{ code: "+855", country: "Cambodia", flag: "🇰🇭" },
{ code: "+856", country: "Laos", flag: "🇱🇦" },
{ code: "+880", country: "Bangladesh", flag: "🇧🇩" },
{ code: "+886", country: "Taiwan", flag: "🇹🇼" },
{ code: "+960", country: "Maldives", flag: "🇲🇻" },
{ code: "+961", country: "Lebanon", flag: "🇱🇧" },
{ code: "+962", country: "Jordan", flag: "🇯🇴" },
{ code: "+963", country: "Syria", flag: "🇸🇾" },
{ code: "+964", country: "Iraq", flag: "🇮🇶" },
{ code: "+965", country: "Kuwait", flag: "🇰🇼" },
{ code: "+966", country: "Saudi Arabia", flag: "🇸🇦" },
{ code: "+967", country: "Yemen", flag: "🇾🇪" },
{ code: "+968", country: "Oman", flag: "🇴🇲" },
{ code: "+970", country: "Palestine", flag: "🇵🇸" },
{ code: "+971", country: "United Arab Emirates", flag: "🇦🇪" },
{ code: "+972", country: "Israel", flag: "🇮🇱" },
{ code: "+973", country: "Bahrain", flag: "🇧🇭" },
{ code: "+974", country: "Qatar", flag: "🇶🇦" },
{ code: "+975", country: "Bhutan", flag: "🇧🇹" },
{ code: "+976", country: "Mongolia", flag: "🇲🇳" },
{ code: "+977", country: "Nepal", flag: "🇳🇵" },
{ code: "+992", country: "Tajikistan", flag: "🇹🇯" },
{ code: "+993", country: "Turkmenistan", flag: "🇹🇲" },
{ code: "+994", country: "Azerbaijan", flag: "🇦🇿" },
{ code: "+995", country: "Georgia", flag: "🇬🇪" },
{ code: "+996", country: "Kyrgyzstan", flag: "🇰🇬" },
{ code: "+998", country: "Uzbekistan", flag: "🇺🇿" },
];
// Phone number validation regex
export const PHONE_REGEX = /^[\+]?[1-9][\d]{0,15}$/;

View File

@ -100,7 +100,7 @@ export default function UserRoleCard({
</Typography> </Typography>
<Stack direction="row" spacing={1} mt={1} flexWrap="wrap"> <Stack direction="row" spacing={1} mt={1} flexWrap="wrap">
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
{roles.map((role) => ( {roles.map(role => (
<Chip key={role} label={role} size="small" /> <Chip key={role} label={role} size="small" />
))} ))}
</Stack> </Stack>

View File

@ -0,0 +1,49 @@
import { PHONE_REGEX } from "./constants";
// Phone validation function
export const validatePhone = (phone: string, countryCode: string): string => {
if (!phone) return ""; // Phone is optional
// Remove any non-digit characters except +
const cleanPhone = phone.replace(/[^\d+]/g, "");
// Check if phone starts with country code
if (cleanPhone.startsWith(countryCode)) {
const phoneNumber = cleanPhone.substring(countryCode.length);
if (phoneNumber.length < 7 || phoneNumber.length > 15) {
return "Phone number must be between 7-15 digits";
}
if (!PHONE_REGEX.test(phoneNumber)) {
return "Invalid phone number format";
}
} else {
// If phone doesn't start with country code, validate the whole number
if (cleanPhone.length < 7 || cleanPhone.length > 15) {
return "Phone number must be between 7-15 digits";
}
if (!PHONE_REGEX.test(cleanPhone)) {
return "Invalid phone number format";
}
}
return "";
};
// Format phone number display
export const formatPhoneDisplay = (
phone: string,
countryCode: string
): string => {
if (!phone) return "";
// Remove any non-digit characters except +
const cleanPhone = phone.replace(/[^\d+]/g, "");
// If phone already includes country code, return as is
if (cleanPhone.startsWith(countryCode)) {
return cleanPhone;
}
// Otherwise, prepend country code
return `${countryCode}${cleanPhone}`;
};

View File

@ -1,5 +1,5 @@
.whats-new { .whats-new {
.whats-new__wifi-icon { .whats-new__wifi-icon {
height: auto; height: auto;
} }
} }

View File

@ -1,19 +1,19 @@
.header { .header {
.header__toolbar { .header__toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
.header__left-group { .header__left-group {
width: 100px; width: 100px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; // optional spacing between menu and dropdown gap: 12px; // optional spacing between menu and dropdown
}
.header__right-group {
margin-left: auto; // pushes it to the far right
display: flex;
align-items: center;
}
} }
.header__right-group {
margin-left: auto; // pushes it to the far right
display: flex;
align-items: center;
}
}
} }

View File

@ -34,6 +34,11 @@ export default function AccountMenu() {
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
const router = useRouter(); const router = useRouter();
const handleGoToSettings = () => {
router.push("/dashboard/settings");
handleClose();
};
// Select relevant state from your auth slice // Select relevant state from your auth slice
const isLoggedIn = useSelector(selectIsLoggedIn); const isLoggedIn = useSelector(selectIsLoggedIn);
const authStatus = useSelector(selectStatus); const authStatus = useSelector(selectStatus);
@ -42,22 +47,10 @@ export default function AccountMenu() {
const isLoggingOut = authStatus === "loading"; const isLoggingOut = authStatus === "loading";
const handleLogout = async () => { const handleLogout = async () => {
// Dispatch the logout thunk // Dispatch the logout thunk - redirection will be handled by the epic
const resultAction = await dispatch(logout()); await dispatch(logout());
// Check if logout was successful based on the action result
if (logout.fulfilled.match(resultAction)) {
console.log("Logout successful, redirecting...");
router.push("/login"); // Redirect to your login page
} else {
// Handle logout failure (e.g., show an error message)
console.error("Logout failed:", resultAction.payload);
// You might want to display a toast or alert here
}
}; };
console.log("[isLoggedin]", isLoggedIn);
// Only show the logout button if the user is logged in // Only show the logout button if the user is logged in
if (!isLoggedIn) { if (!isLoggedIn) {
return null; return null;
@ -84,7 +77,7 @@ export default function AccountMenu() {
<Typography variant="inherit">Account</Typography> <Typography variant="inherit">Account</Typography>
</MenuItem> </MenuItem>
<MenuItem> <MenuItem onClick={handleGoToSettings}>
<ListItemIcon> <ListItemIcon>
<SettingsIcon fontSize="small" /> <SettingsIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>

View File

@ -1,8 +1,8 @@
.sidebar-dropdown__container { .sidebar-dropdown__container {
.page-link__container { .page-link__container {
color: var(--text-secondary); color: var(--text-secondary);
.page-link__text { .page-link__text {
color: var(--text-primary); color: var(--text-primary);
}
} }
}
} }

View File

@ -1,8 +1,8 @@
import { styled } from '@mui/system'; import { styled } from "@mui/system";
export const MainContent = styled('div')(({ theme }) => ({ export const MainContent = styled("div")(({ theme }) => ({
marginLeft: '240px', marginLeft: "240px",
padding: theme.spacing(3), padding: theme.spacing(3),
minHeight: '100vh', minHeight: "100vh",
width: 'calc(100% - 240px)', width: "calc(100% - 240px)",
})); }));

View File

@ -12,7 +12,7 @@ const SideBar = () => {
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({}); const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
const toggleMenu = (title: string) => { const toggleMenu = (title: string) => {
setOpenMenus((prev) => ({ ...prev, [title]: !prev[title] })); setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
}; };
return ( return (
@ -24,7 +24,7 @@ const SideBar = () => {
</span> </span>
</div> </div>
{PAGE_LINKS.map((link) => {PAGE_LINKS.map(link =>
link.children ? ( link.children ? (
<div key={link.title}> <div key={link.title}>
<button <button
@ -43,7 +43,7 @@ const SideBar = () => {
</button> </button>
{openMenus[link.title] && ( {openMenus[link.title] && (
<div className="sidebar__submenu"> <div className="sidebar__submenu">
{link.children.map((child) => ( {link.children.map(child => (
<PageLinks <PageLinks
key={child.path} key={child.path}
title={child.title} title={child.title}

View File

@ -11,12 +11,11 @@ import InsightsIcon from "@mui/icons-material/Insights";
import ListAltIcon from "@mui/icons-material/ListAlt"; import ListAltIcon from "@mui/icons-material/ListAlt";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from "@mui/icons-material/History";
import FactCheckIcon from "@mui/icons-material/FactCheck"; import FactCheckIcon from "@mui/icons-material/FactCheck";
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces"; import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces";
export const PAGE_LINKS: ISidebarLink[] = [ export const PAGE_LINKS: ISidebarLink[] = [
@ -25,7 +24,8 @@ export const PAGE_LINKS: ISidebarLink[] = [
{ {
title: "Transaction", title: "Transaction",
path: "/dashboard/transactions", path: "/dashboard/transactions",
icon: AccountBalanceWalletIcon, children: [ icon: AccountBalanceWalletIcon,
children: [
{ {
title: "Deposits", title: "Deposits",
path: "/dashboard/transactions/deposits", path: "/dashboard/transactions/deposits",

View File

@ -36,10 +36,13 @@ export function useTokenExpiration() {
}, logoutDelay); }, logoutDelay);
// Also set up periodic checks every 5 minutes // Also set up periodic checks every 5 minutes
const checkInterval = setInterval(() => { const checkInterval = setInterval(
// Re-dispatch checkAuthStatus to get updated expiration time () => {
// This will be handled by the ReduxProvider // Re-dispatch checkAuthStatus to get updated expiration time
}, 5 * 60 * 1000); // 5 minutes // This will be handled by the ReduxProvider
},
5 * 60 * 1000
); // 5 minutes
return () => { return () => {
if (timeoutRef.current) { if (timeoutRef.current) {

View File

@ -1,8 +1,10 @@
import ThemeRegistry from "@/config/ThemeRegistry"; // app/layout.tsx
import type { Metadata } from "next"; import type { Metadata } from "next";
import ReduxProvider from "./redux/ReduxProvider"; import ThemeRegistry from "@/config/ThemeRegistry";
import ReduxProvider from "./ReduxProvider"; // moved into app/
import { AuthBootstrap } from "./AuthBootstrap"; // moved into app/
import { Toaster } from "react-hot-toast";
import "../styles/globals.scss"; import "../styles/globals.scss";
import { InitializeAuth } from "./redux/InitializeAuth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Your App", title: "Your App",
@ -18,8 +20,10 @@ export default function RootLayout({
<html lang="en"> <html lang="en">
<body> <body>
<ReduxProvider> <ReduxProvider>
<InitializeAuth /> {/* Bootstraps session validation + redirect if invalid */}
<AuthBootstrap />
<ThemeRegistry>{children}</ThemeRegistry> <ThemeRegistry>{children}</ThemeRegistry>
<Toaster position="top-right" reverseOrder={false} />
</ReduxProvider> </ReduxProvider>
</body> </body>
</html> </html>

View File

@ -10,9 +10,15 @@ import {
selectAuthMessage, selectAuthMessage,
selectIsLoggedIn, selectIsLoggedIn,
selectStatus, selectStatus,
selectUser,
} from "../redux/auth/selectors"; } from "../redux/auth/selectors";
import { clearAuthMessage, login } from "../redux/auth/authSlice"; import {
clearAuthMessage,
login,
changePassword,
} from "../redux/auth/authSlice";
import "./page.scss"; import "./page.scss";
import { ChangePassword } from "../features/Auth/ChangePassword/ChangePassword";
export default function LoginPageClient() { export default function LoginPageClient() {
const router = useRouter(); const router = useRouter();
@ -22,8 +28,14 @@ export default function LoginPageClient() {
const isLoggedIn = useSelector(selectIsLoggedIn); const isLoggedIn = useSelector(selectIsLoggedIn);
const status = useSelector(selectStatus); const status = useSelector(selectStatus);
const authMessage = useSelector(selectAuthMessage); const authMessage = useSelector(selectAuthMessage);
const user = useSelector(selectUser);
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
const [redirectMessage, setRedirectMessage] = useState<string>(""); const [redirectMessage, setRedirectMessage] = useState<string>("");
const [mustChangePassword, setMustChangePassword] = useState<boolean>(
reason === "change-password"
);
console.log("[DEBUG] [reason]:", reason === "change-password");
useEffect(() => { useEffect(() => {
// Set message based on redirect reason // Set message based on redirect reason
@ -33,14 +45,22 @@ export default function LoginPageClient() {
setRedirectMessage("Invalid session detected. Please log in again."); setRedirectMessage("Invalid session detected. Please log in again.");
} else if (reason === "no-token") { } else if (reason === "no-token") {
setRedirectMessage("Please log in to access the backoffice."); setRedirectMessage("Please log in to access the backoffice.");
} else if (reason === "change-password") {
setRedirectMessage(
"Please change your password to access the backoffice."
);
} }
}, [reason]); }, [reason]);
useEffect(() => { useEffect(() => {
if (isLoggedIn && status === "succeeded") { setMustChangePassword(reason === "change-password" ? true : false);
}, [reason]);
useEffect(() => {
if (isLoggedIn && status === "succeeded" && !mustChangePassword) {
router.replace(redirectPath); router.replace(redirectPath);
} }
}, [isLoggedIn, status, router, redirectPath]); }, [isLoggedIn, status, mustChangePassword, router, redirectPath]);
const handleLogin = async (email: string, password: string) => { const handleLogin = async (email: string, password: string) => {
const resultAction = await dispatch(login({ email, password })); const resultAction = await dispatch(login({ email, password }));
@ -51,7 +71,31 @@ export default function LoginPageClient() {
dispatch(clearAuthMessage()); dispatch(clearAuthMessage());
}; };
if (isLoggedIn) { const handleChangePassword = async (passwordData: {
currentPassword?: string;
newPassword: string;
}) => {
if (!user?.email) {
console.error("No user email available for password change");
return false;
}
const resultAction = await dispatch(
changePassword({
email: user.email,
newPassword: passwordData.newPassword,
currentPassword: passwordData.currentPassword,
})
);
const result = changePassword.fulfilled.match(resultAction);
// if(result && resultAction.payload.success) {
// setMustChangePassword(!resultAction.payload.success);
// }
return result;
};
if (isLoggedIn && !mustChangePassword) {
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-container__content"> <div className="page-container__content">
@ -66,13 +110,23 @@ export default function LoginPageClient() {
return ( return (
<div className="page-container"> <div className="page-container">
<Modal open={true} title="Login to Payment Cashier"> {mustChangePassword ? (
<LoginModal <Modal open={true} title="Change Your Temporary Password">
onLogin={handleLogin} <ChangePassword
authMessage={authMessage} open={true}
clearAuthMessage={handleClearAuthMessage} onClose={() => {}}
/> onSubmit={handleChangePassword}
</Modal> />
</Modal>
) : (
<Modal open={true} title="Login to Payment Cashier">
<LoginModal
onLogin={handleLogin}
authMessage={authMessage}
clearAuthMessage={handleClearAuthMessage}
/>
</Modal>
)}
</div> </div>
); );
} }

View File

@ -1,3 +1,5 @@
@use "sass:color";
// Variables for consistent styling // Variables for consistent styling
$primary-color: #2563eb; // Blue-600 equivalent $primary-color: #2563eb; // Blue-600 equivalent
$primary-hover-color: #1d4ed8; // Blue-700 equivalent $primary-hover-color: #1d4ed8; // Blue-700 equivalent
@ -26,7 +28,8 @@ $bg-color-white: #ffffff;
max-width: 56rem; // max-w-4xl max-width: 56rem; // max-w-4xl
background-color: $bg-color-white; background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl border-radius: 0.75rem; // rounded-xl
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg 0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
padding: 2rem; // p-8 padding: 2rem; // p-8
text-align: center; text-align: center;
@ -59,6 +62,9 @@ $bg-color-white: #ffffff;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md
transition: background-color 0.3s ease-in-out; transition: background-color 0.3s ease-in-out;
&:hover { &:hover {
background-color: darken($error-color, 5%); // red-700 equivalent background-color: color.adjust(
$error-color,
$lightness: -5%
); // red-700 equivalent
} }
} }

View File

@ -24,9 +24,12 @@ export default function ReduxProvider({
}, 2000); }, 2000);
// Set up periodic token validation every 5 minutes // Set up periodic token validation every 5 minutes
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(
store.dispatch(checkAuthStatus()); () => {
}, 5 * 60 * 1000); // 5 minutes store.dispatch(checkAuthStatus());
},
5 * 60 * 1000
); // 5 minutes
return () => { return () => {
if (intervalRef.current) { if (intervalRef.current) {

View File

@ -1,20 +1,21 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import { ThunkSuccess, ThunkError, IUserResponse } from "../types";
import toast from "react-hot-toast";
interface TokenInfo {
expiresAt: string;
timeUntilExpiration: number;
expiresInHours: number;
}
// Define the initial state for the authentication slice
interface AuthState { interface AuthState {
isLoggedIn: boolean; isLoggedIn: boolean;
authMessage: string; authMessage: string;
status: "idle" | "loading" | "succeeded" | "failed"; status: "idle" | "loading" | "succeeded" | "failed";
error: string | null; error: string | null;
user: { user: IUserResponse | null;
email: string; tokenInfo: TokenInfo | null;
role: string; mustChangePassword: boolean;
} | null;
tokenInfo: {
expiresAt: string;
timeUntilExpiration: number;
expiresInHours: number;
} | null;
} }
const initialState: AuthState = { const initialState: AuthState = {
@ -24,318 +25,295 @@ const initialState: AuthState = {
error: null, error: null,
user: null, user: null,
tokenInfo: null, tokenInfo: null,
mustChangePassword: false,
}; };
// Async Thunk for Login // ---------------- Login ----------------
// This handles the API call to your Next.js login Route Handler export const login = createAsyncThunk<
export const login = createAsyncThunk( ThunkSuccess<{
user: IUserResponse | null;
tokenInfo: TokenInfo | null;
mustChangePassword: boolean;
}>,
{ email: string; password: string },
{ rejectValue: ThunkError }
>(
"auth/login", "auth/login",
async ( async (
{ email, password }: { email: string; password: string }, { email, password }: { email: string; password: string },
{ rejectWithValue } { rejectWithValue }
) => { ) => {
try { try {
const response = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
const data = await response.json(); const data = await res.json();
if (!res.ok) {
if (!response.ok) { toast.error(data.message || "Login failed");
// If the server responded with an error status (e.g., 401, 400, 500) return rejectWithValue((data.message as string) || "Login failed");
return rejectWithValue(data.message || "Login failed");
} }
// On successful login, the backend sets the HTTP-only cookie. // After login, validate session right away
// We'll set a client-side flag (like localStorage) for immediate UI updates, const validateRes = await fetch("/api/auth/validate", { method: "POST" });
// though the primary source of truth for auth is the HTTP-only cookie. const validateData = await validateRes.json();
if (typeof window !== "undefined") {
// Ensure localStorage access is client-side
localStorage.setItem("userToken", "mock-authenticated"); // For client-side state sync
}
// After successful login, check auth status to get token information toast.success(data.message || "Login successful");
// This ensures we have the token expiration details immediately
const authStatusResponse = await fetch("/api/auth/status");
if (authStatusResponse.ok) {
const authData = await authStatusResponse.json();
return {
message: data.message || "Login successful",
user: authData.user,
tokenInfo: authData.tokenInfo,
};
}
return data.message || "Login successful"; return {
} catch (error: unknown) { message: data.message || "Login successful",
// Handle network errors or other unexpected issues user: data.user || null,
const errorMessage = tokenInfo: validateData.tokenInfo || null,
error instanceof Error ? error.message : "Network error during login"; mustChangePassword: data.must_change_password || false,
return rejectWithValue(errorMessage); } as ThunkSuccess<{
user: IUserResponse | null;
tokenInfo: TokenInfo | null;
mustChangePassword: boolean;
}>;
} catch (err: unknown) {
return rejectWithValue(
(err as Error).message || "Network error during login"
);
} }
} }
); );
// Async Thunk for Logout // ---------------- Logout ----------------
// This handles the API call to your Next.js logout Route Handler export const logout = createAsyncThunk<
export const logout = createAsyncThunk( ThunkSuccess,
"auth/logout", void,
async (_, { rejectWithValue }) => { { rejectValue: ThunkError }
>("auth/logout", async (_, { rejectWithValue }) => {
try {
const res = await fetch("/api/auth/logout", { method: "DELETE" });
const data = await res.json();
if (!res.ok)
return rejectWithValue((data.message as string) || "Logout failed");
// Persisted state will be cleared by reducers below; avoid direct persistor usage here.
return { message: data.message || "Logged out successfully" };
} catch (err: unknown) {
return rejectWithValue(
(err as Error).message || "Network error during logout"
);
}
});
// ---------------- Auto Logout ----------------
export const autoLogout = createAsyncThunk<
ThunkSuccess,
string,
{ rejectValue: ThunkError }
>("auth/autoLogout", async (reason: string, { rejectWithValue }) => {
try {
await fetch("/api/auth/logout", { method: "DELETE" });
return { message: `Auto-logout: ${reason}` };
} catch (err: unknown) {
return rejectWithValue((err as Error).message || "Auto-logout failed");
}
});
// ---------------- Change Password ----------------
export const changePassword = createAsyncThunk<
ThunkSuccess<{ success: boolean }>,
{ email: string; newPassword: string; currentPassword?: string },
{ rejectValue: ThunkError }
>(
"auth/changePassword",
async ({ email, newPassword, currentPassword }, { rejectWithValue }) => {
try { try {
const response = await fetch("/api/auth/logout", { const res = await fetch("/api/auth/change-password", {
method: "DELETE", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, newPassword, currentPassword }),
}); });
const data = await response.json(); const data = await res.json();
if (!res.ok)
return rejectWithValue(
(data.message as string) || "Password change failed"
);
if (!response.ok) { return {
// If the server responded with an error status success: data.success,
return rejectWithValue(data.message || "Logout failed"); message: data.message || "Password changed successfully",
} };
} catch (err: unknown) {
if (typeof window !== "undefined") { return rejectWithValue(
// Ensure localStorage access is client-side (err as Error).message || "Network error during password change"
localStorage.removeItem("userToken"); // Clear client-side flag );
}
// After successful logout, check auth status to ensure proper cleanup
const authStatusResponse = await fetch("/api/auth/status");
if (authStatusResponse.ok) {
const authData = await authStatusResponse.json();
return {
message: data.message || "Logged out successfully",
isAuthenticated: authData.isAuthenticated,
};
}
return data.message || "Logged out successfully";
} catch (error: unknown) {
// Handle network errors
const errorMessage =
error instanceof Error ? error.message : "Network error during logout";
return rejectWithValue(errorMessage);
} }
} }
); );
// Async Thunk for automatic logout (when token expires) // ---------------- Unified Validate Auth ----------------
export const autoLogout = createAsyncThunk( export const validateAuth = createAsyncThunk<
"auth/autoLogout", { tokenInfo: TokenInfo | null },
async (reason: string, { rejectWithValue }) => { void,
try { { rejectValue: ThunkError }
// Clear the cookie by calling logout endpoint >("auth/validate", async (_, { rejectWithValue }) => {
await fetch("/api/auth/logout", { try {
method: "DELETE", const res = await fetch("/api/auth/validate", { method: "POST" });
}); const data = await res.json();
if (typeof window !== "undefined") { if (!res.ok || !data.valid) {
// Clear client-side storage return rejectWithValue("Session invalid or expired");
localStorage.removeItem("userToken");
}
return { message: `Auto-logout: ${reason}` };
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Auto-logout failed";
return rejectWithValue(errorMessage);
} }
return {
tokenInfo: data.tokenInfo || null,
};
} catch (err: unknown) {
return rejectWithValue(
(err as Error).message || "Network error during validation"
);
} }
); });
// Async Thunk for manual refresh of authentication status // TODO - Creaye a new thunk to update the user stuff
export const refreshAuthStatus = createAsyncThunk(
"auth/refreshStatus",
async (_, { rejectWithValue }) => {
try {
const response = await fetch("/api/auth/status");
const data = await response.json();
if (!response.ok) { // ---------------- Update User Details ----------------
return rejectWithValue(data.message || "Authentication refresh failed"); export const updateUserDetails = createAsyncThunk<
} ThunkSuccess<{ user: IUserResponse }>,
{ id: string; updates: Partial<IUserResponse> },
{ rejectValue: ThunkError }
>("auth/updateUserDetails", async ({ id, updates }, { rejectWithValue }) => {
try {
const res = await fetch(`/api/dashboard/admin/users/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updates),
});
return data; const data = await res.json();
} catch (error: unknown) {
const errorMessage = if (!res.ok) {
error instanceof Error return rejectWithValue(data.message || "Failed to update user details");
? error.message
: "Network error during auth refresh";
return rejectWithValue(errorMessage);
} }
return {
message: data.message || "User details updated successfully",
user: data.user,
};
} catch (err: unknown) {
return rejectWithValue(
(err as Error).message || "Network error during user update"
);
} }
); });
// Async Thunk for checking authentication status // ---------------- Slice ----------------
export const checkAuthStatus = createAsyncThunk(
"auth/checkStatus",
async (_, { rejectWithValue }) => {
try {
const response = await fetch("/api/auth/status");
const data = await response.json();
if (!response.ok) {
return rejectWithValue(data.message || "Authentication check failed");
}
return data;
} catch (error: unknown) {
const errorMessage =
error instanceof Error
? error.message
: "Network error during auth check";
return rejectWithValue(errorMessage);
}
}
);
// Create the authentication slice
const authSlice = createSlice({ const authSlice = createSlice({
name: "auth", name: "auth",
initialState, initialState,
reducers: { reducers: {
// Reducer to set an authentication message (e.g., from UI actions)
setAuthMessage: (state, action: PayloadAction<string>) => { setAuthMessage: (state, action: PayloadAction<string>) => {
state.authMessage = action.payload; state.authMessage = action.payload;
}, },
// Reducer to clear the authentication message clearAuthMessage: state => {
clearAuthMessage: (state) => {
state.authMessage = ""; state.authMessage = "";
}, },
// Reducer to initialize login status from client-side storage (e.g., on app load)
// This is useful for cases where middleware might not redirect immediately,
// or for client-side rendering of protected content based on initial state.
initializeAuth: (state) => {
if (typeof window !== "undefined") {
// Ensure this runs only on the client
const userToken = localStorage.getItem("userToken");
state.isLoggedIn = userToken === "mock-authenticated";
}
},
}, },
extraReducers: (builder) => { extraReducers: builder => {
builder builder
// Login Thunk Reducers // Login
.addCase(login.pending, (state) => { .addCase(login.pending, state => {
state.status = "loading"; state.status = "loading";
state.error = null;
state.authMessage = "Attempting login..."; state.authMessage = "Attempting login...";
}) })
.addCase(login.fulfilled, (state, action) => { .addCase(login.fulfilled, (state, action) => {
state.status = "succeeded"; state.status = "succeeded";
state.isLoggedIn = true; state.isLoggedIn = true;
// Handle both old string payload and new object payload state.authMessage = action.payload.message;
if (typeof action.payload === "string") { state.user = action.payload.user;
state.authMessage = action.payload; state.tokenInfo = action.payload.tokenInfo;
} else { state.mustChangePassword = action.payload.mustChangePassword;
state.authMessage = action.payload.message;
state.user = action.payload.user || null;
state.tokenInfo = action.payload.tokenInfo || null;
}
}) })
.addCase(login.rejected, (state, action) => { .addCase(login.rejected, (state, action) => {
state.status = "failed"; state.status = "failed";
state.isLoggedIn = false; state.isLoggedIn = false;
state.error = action.payload as string; state.error = action.payload as string;
state.authMessage = action.payload as string; // Display error message state.authMessage = action.payload as string;
})
// Logout Thunk Reducers
.addCase(logout.pending, (state) => {
state.status = "loading";
state.error = null;
state.authMessage = "Logging out...";
}) })
// Logout
.addCase(logout.fulfilled, (state, action) => { .addCase(logout.fulfilled, (state, action) => {
state.status = "succeeded"; state.status = "succeeded";
state.isLoggedIn = false; state.isLoggedIn = false;
// Handle both old string payload and new object payload state.authMessage = action.payload.message;
if (typeof action.payload === "string") {
state.authMessage = action.payload;
} else {
state.authMessage = action.payload.message;
// Ensure we're properly logged out
if (action.payload.isAuthenticated === false) {
state.user = null;
state.tokenInfo = null;
}
}
// Always clear user data on logout
state.user = null; state.user = null;
state.tokenInfo = null; state.tokenInfo = null;
state.mustChangePassword = false;
}) })
.addCase(logout.rejected, (state, action) => { .addCase(logout.rejected, (state, action) => {
state.status = "failed"; state.status = "failed";
state.isLoggedIn = true; // Stay logged in if logout failed
state.error = action.payload as string; state.error = action.payload as string;
state.authMessage = action.payload as string; // Display error message state.authMessage = action.payload as string;
})
// Check Auth Status Thunk Reducers
.addCase(checkAuthStatus.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(checkAuthStatus.fulfilled, (state, action) => {
state.status = "succeeded";
state.isLoggedIn = action.payload.isAuthenticated;
state.user = action.payload.user || null;
state.tokenInfo = action.payload.tokenInfo || null;
state.error = null;
})
.addCase(checkAuthStatus.rejected, (state, action) => {
state.status = "failed";
state.isLoggedIn = false;
state.user = null;
state.tokenInfo = null;
state.error = action.payload as string;
})
// Auto Logout Thunk Reducers
.addCase(autoLogout.pending, (state) => {
state.status = "loading";
state.error = null;
state.authMessage = "Session expired, logging out...";
}) })
// Auto Logout
.addCase(autoLogout.fulfilled, (state, action) => { .addCase(autoLogout.fulfilled, (state, action) => {
state.status = "succeeded"; state.status = "succeeded";
state.isLoggedIn = false; state.isLoggedIn = false;
state.authMessage = action.payload.message; state.authMessage = action.payload.message;
state.user = null; state.user = null;
state.tokenInfo = null; state.tokenInfo = null;
state.error = null; state.mustChangePassword = false;
}) })
.addCase(autoLogout.rejected, (state, action) => { .addCase(autoLogout.rejected, (state, action) => {
state.status = "failed"; state.status = "failed";
state.isLoggedIn = false;
state.user = null;
state.tokenInfo = null;
state.error = action.payload as string; state.error = action.payload as string;
state.authMessage = "Auto-logout failed";
}) })
// Refresh Auth Status Thunk Reducers // Change Password
.addCase(refreshAuthStatus.pending, (state) => { .addCase(changePassword.pending, state => {
state.status = "loading"; state.status = "loading";
state.error = null; state.authMessage = "Changing password...";
}) })
.addCase(refreshAuthStatus.fulfilled, (state, action) => { .addCase(changePassword.fulfilled, (state, action) => {
state.status = "succeeded"; state.status = "succeeded";
state.isLoggedIn = action.payload.isAuthenticated; state.authMessage = action.payload.message;
state.user = action.payload.user || null; state.mustChangePassword = action.payload.success;
state.tokenInfo = action.payload.tokenInfo || null;
state.error = null;
}) })
.addCase(refreshAuthStatus.rejected, (state, action) => { .addCase(changePassword.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload as string;
state.authMessage = action.payload as string;
})
// Validate Auth
.addCase(validateAuth.pending, state => {
state.status = "loading";
})
.addCase(validateAuth.fulfilled, (state, action) => {
state.status = "succeeded";
state.isLoggedIn = true;
state.tokenInfo = action.payload.tokenInfo;
})
.addCase(validateAuth.rejected, (state, action) => {
state.status = "failed"; state.status = "failed";
state.isLoggedIn = false; state.isLoggedIn = false;
state.user = null;
state.tokenInfo = null; state.tokenInfo = null;
state.error = action.payload as string; state.error = action.payload as string;
})
// Update User Details
.addCase(updateUserDetails.pending, state => {
state.status = "loading";
state.authMessage = "Updating user details...";
})
.addCase(updateUserDetails.fulfilled, (state, action) => {
state.status = "succeeded";
state.user = action.payload.user;
state.authMessage = action.payload.message;
})
.addCase(updateUserDetails.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload as string;
state.authMessage = action.payload as string;
}); });
}, },
}); });
export const { setAuthMessage, clearAuthMessage, initializeAuth } = export const { setAuthMessage, clearAuthMessage } = authSlice.actions;
authSlice.actions;
export default authSlice.reducer; export default authSlice.reducer;

17
app/redux/auth/epic.ts Normal file
View File

@ -0,0 +1,17 @@
import { Epic } from "redux-observable";
import { logout } from "./authSlice";
import { filter, tap, ignoreElements } from "rxjs/operators";
export const logoutRedirectEpic: Epic = action$ =>
action$.pipe(
filter(logout.fulfilled.match),
tap(() => {
// Use window.location for redirection in epics since we can't use hooks
window.location.href = "/login";
}),
ignoreElements()
);
const authEpics = [logoutRedirectEpic];
export default authEpics;

View File

@ -6,6 +6,8 @@ export const selectError = (state: RootState) => state.auth?.error;
export const selectAuthMessage = (state: RootState) => state.auth?.authMessage; export const selectAuthMessage = (state: RootState) => state.auth?.authMessage;
export const selectUser = (state: RootState) => state.auth?.user; export const selectUser = (state: RootState) => state.auth?.user;
export const selectTokenInfo = (state: RootState) => state.auth?.tokenInfo; export const selectTokenInfo = (state: RootState) => state.auth?.tokenInfo;
export const selectMustChangePassword = (state: RootState) =>
state.auth?.mustChangePassword;
export const selectTimeUntilExpiration = (state: RootState) => export const selectTimeUntilExpiration = (state: RootState) =>
state.auth?.tokenInfo?.timeUntilExpiration || 0; state.auth?.tokenInfo?.timeUntilExpiration || 0;
export const selectExpiresInHours = (state: RootState) => export const selectExpiresInHours = (state: RootState) =>

View File

@ -1,17 +1,70 @@
import { configureStore } from "@reduxjs/toolkit"; import {
configureStore,
combineReducers,
type Reducer,
} from "@reduxjs/toolkit";
import storage from "redux-persist/lib/storage"; // defaults to localStorage for web
import { createTransform, persistReducer, persistStore } from "redux-persist";
import { createEpicMiddleware, combineEpics } from "redux-observable";
import advancedSearchReducer from "./advanedSearch/advancedSearchSlice"; import advancedSearchReducer from "./advanedSearch/advancedSearchSlice";
import authReducer from "./auth/authSlice"; import authReducer from "./auth/authSlice";
import userEpics from "./user/epic";
import authEpics from "./auth/epic";
export const makeStore = () => { type PersistedAuth = { user: unknown | null };
return configureStore({
reducer: { const persistConfig = {
advancedSearch: advancedSearchReducer, key: "root",
auth: authReducer, storage,
}, whitelist: ["auth"], // only persist auth slice
// Enable Redux DevTools transforms: [
devTools: process.env.NODE_ENV !== "production", // Persist only the `user` subkey from the auth slice
}); createTransform(
(inboundState: unknown): PersistedAuth => {
const s = inboundState as { user?: unknown } | null | undefined;
return { user: s?.user ?? null };
},
(outboundState: unknown): PersistedAuth => {
const s = outboundState as { user?: unknown } | null | undefined;
return { user: s?.user ?? null };
},
{ whitelist: ["auth"] }
),
],
}; };
// Create the store instance const rootReducer = combineReducers({
advancedSearch: advancedSearchReducer,
auth: authReducer,
});
const rootEpic = combineEpics(...userEpics, ...authEpics);
const persistedReducer = persistReducer(persistConfig, rootReducer as Reducer);
// Create epic middleware
const epicMiddleware = createEpicMiddleware();
export const makeStore = () => {
const store = configureStore({
reducer: persistedReducer,
devTools: process.env.NODE_ENV !== "production",
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false, // redux-persist uses non-serializable values
}).concat(epicMiddleware),
});
// Run the epic
epicMiddleware.run(rootEpic);
return store;
};
// Create store + persistor
export const store = makeStore(); export const store = makeStore();
export const persistor = persistStore(store);
// Types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -3,3 +3,42 @@ import { store } from "./store";
export type RootState = ReturnType<typeof store.getState>; export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch; export type AppDispatch = typeof store.dispatch;
// Generic helper types for Redux thunks that return a user-facing message
export type ThunkSuccess<T extends object = object> = { message: string } & T;
export type ThunkError = string;
export interface IUserResponse {
token: string;
id: string;
name: string | null;
email: string;
firstName: string | null;
lastName: string | null;
merchantId?: number | null;
username?: string | null;
phone?: string | null;
jobTitle?: string | null;
enabled?: boolean;
authorities?: string[];
allowedMerchantIds?: number[];
created?: string;
disabledBy?: string | null;
disabledDate?: string | null;
disabledReason?: string | null;
incidentNotes?: boolean;
lastLogin?: string | null;
lastMandatoryUpdated?: string | null;
marketingNewsletter?: boolean;
releaseNotes?: boolean;
requiredActions?: string[];
twoFactorCondition?: string | null;
twoFactorCredentials?: string[];
mustChangePassword?: boolean;
}
export interface ILoginResponse {
user: IUserResponse;
success: boolean;
message: string;
must_change_password: boolean;
}

38
app/redux/user/epic.ts Normal file
View File

@ -0,0 +1,38 @@
import { Epic } from "redux-observable";
import { RootState } from "../types";
import { changePassword, logout } from "../auth/authSlice";
import toast from "react-hot-toast";
import { filter, switchMap } from "rxjs/operators";
import { of } from "rxjs";
export const changePasswordEpic: Epic<any, any, RootState> = action$ =>
action$.pipe(
// Listen for any action related to changePassword (pending, fulfilled, rejected)
filter(action => action.type.startsWith(changePassword.typePrefix)),
switchMap(action => {
if (changePassword.fulfilled.match(action)) {
toast.success(
action.payload?.message || "Password changed successfully"
);
// Logout the user after successful password change
return of(logout());
}
if (changePassword.rejected.match(action)) {
const errorMessage =
(action.payload as string) ||
action.error?.message ||
"Password change failed";
toast.error(errorMessage);
return of({ type: "CHANGE_PASSWORD_ERROR_HANDLED" });
}
// If it's pending or something else, just ignore it.
return of({ type: "CHANGE_PASSWORD_NOOP" });
})
);
const userEpics = [changePasswordEpic];
export default userEpics;

View File

@ -1,13 +1,9 @@
export async function getApproves({ export async function getApproves({ query }: { query: string }) {
query,
}: {
query: string;
}) {
const res = await fetch( const res = await fetch(
`http://localhost:3000/api/dashboard/approve?${query}`, `http://localhost:4000/api/dashboard/approve?${query}`,
{ {
cache: "no-store", cache: "no-store",
}, }
); );
if (!res.ok) { if (!res.ok) {

View File

@ -1,13 +1,9 @@
export async function getAudits({ export async function getAudits({ query }: { query: string }) {
query,
}: {
query: string;
}) {
const res = await fetch( const res = await fetch(
`http://localhost:3000/api/dashboard/audits?${query}`, `http://localhost:4000/api/dashboard/audits?${query}`,
{ {
cache: "no-store", cache: "no-store",
}, }
); );
if (!res.ok) { if (!res.ok) {

View File

@ -5,12 +5,11 @@ export async function getTransactions({
transactionType: string; transactionType: string;
query: string; query: string;
}) { }) {
const res = await fetch( const res = await fetch(
`http://localhost:3000/api/dashboard/transactions/${transactionType}?${query}`, `http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`,
{ {
cache: "no-store", cache: "no-store",
}, }
); );
if (!res.ok) { if (!res.ok) {

View File

@ -1,17 +1,12 @@
export async function getTransactionsHistory({ export async function getTransactionsHistory({ query }: { query: string }) {
query, const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
}: { ? `${process.env.NEXT_PUBLIC_BASE_URL}`
query: string; : "http://localhost:4000";
}) {
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL
? `${process.env.NEXT_PUBLIC_BASE_URL}`
: "http://localhost:3000";
const res = await fetch( const res = await fetch(
`${baseUrl}/api/dashboard/transactionsHistory?${query}`, `${baseUrl}/api/dashboard/transactionsHistory?${query}`,
{ {
cache: "no-store", cache: "no-store",
}, }
); );
if (!res.ok) { if (!res.ok) {

View File

@ -1,7 +1,8 @@
import { jwtVerify } from "jose"; import { jwtVerify } from "jose";
// Secret key for JWT verification (must match the one used for signing) // Secret key for JWT verification (must match the one used for signing)
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET); const rawSecret = (process.env.JWT_SECRET ?? "").trim().replace(/^"|"$/g, "");
const JWT_SECRET = new TextEncoder().encode(rawSecret);
export interface JWTPayload { export interface JWTPayload {
email: string; email: string;
@ -15,7 +16,10 @@ export interface JWTPayload {
*/ */
export async function validateToken(token: string): Promise<JWTPayload | null> { export async function validateToken(token: string): Promise<JWTPayload | null> {
try { try {
const { payload } = await jwtVerify(token, JWT_SECRET); const raw = token.startsWith("Bearer ") ? token.slice(7) : token;
const { payload } = await jwtVerify(raw, JWT_SECRET, {
algorithms: ["HS256"],
});
return payload as unknown as JWTPayload; return payload as unknown as JWTPayload;
} catch (error) { } catch (error) {
console.error("Token validation error:", error); console.error("Token validation error:", error);
@ -45,3 +49,19 @@ export function getTimeUntilExpiration(payload: JWTPayload): number {
const currentTime = Math.floor(Date.now() / 1000); const currentTime = Math.floor(Date.now() / 1000);
return Math.max(0, payload.exp - currentTime); return Math.max(0, payload.exp - currentTime);
} }
export function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) errors.push("At least 8 characters");
if (!/[A-Z]/.test(password)) errors.push("One uppercase letter");
if (!/[a-z]/.test(password)) errors.push("One lowercase letter");
if (!/[0-9]/.test(password)) errors.push("One number");
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password))
errors.push("One special character");
return {
valid: errors.length === 0,
errors,
};
}

View File

@ -8,12 +8,14 @@ export const exportData = <TRow, TColumn extends GridColDef>(
columns: TColumn[], columns: TColumn[],
fileType: FileType = "csv", fileType: FileType = "csv",
onlyCurrentTable = false, onlyCurrentTable = false,
setOpen: (open: boolean) => void, setOpen: (open: boolean) => void
) => { ) => {
const exportRows = onlyCurrentTable ? rows.slice(0, 5) : rows; const exportRows = onlyCurrentTable ? rows.slice(0, 5) : rows;
const exportData = [ const exportData = [
columns.map((col) => col.headerName), columns.map(col => col.headerName),
...exportRows.map((row) => columns.map((col) => (row as Record<string, unknown>)[col.field] ?? "")), ...exportRows.map(row =>
columns.map(col => (row as Record<string, unknown>)[col.field] ?? "")
),
]; ];
const worksheet = XLSX.utils.aoa_to_sheet(exportData); const worksheet = XLSX.utils.aoa_to_sheet(exportData);

View File

@ -2,12 +2,12 @@ export const formatToDateTimeString = (dateString: string): string => {
const date = new Date(dateString); const date = new Date(dateString);
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // months are 0-indexed const month = String(date.getMonth() + 1).padStart(2, "0"); // months are 0-indexed
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
} };

View File

@ -1,30 +1,81 @@
// middleware.ts
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";
export function middleware(request: NextRequest) { const COOKIE_NAME = "auth_token";
const token = request.cookies.get("auth_token")?.value; // Get token from cookie const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
// Define protected paths function isExpired(exp?: number) {
const protectedPaths = ["/dashboard", "/settings", "/admin"]; return exp ? exp * 1000 <= Date.now() : false;
const isProtected = protectedPaths.some((path) => }
request.nextUrl.pathname.startsWith(path)
);
// If accessing a protected path and no token async function validateToken(token: string) {
if (isProtected && !token) { const raw = token.startsWith("Bearer ") ? token.slice(7) : token;
// Redirect to login page
const loginUrl = new URL("/login", request.url); try {
// Optional: Add a redirect query param to return to original page after login const { payload } = await jwtVerify(raw, JWT_SECRET, {
loginUrl.searchParams.set("redirect", request.nextUrl.pathname); algorithms: ["HS256"],
});
return payload as {
exp?: number;
MustChangePassword?: boolean;
[key: string]: unknown;
};
} catch (err) {
console.error("Token validation error:", err);
return null;
}
}
export async function middleware(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAME)?.value;
const loginUrl = new URL("/login", request.url);
const currentPath = request.nextUrl.pathname;
// 1⃣ No token
if (!token) {
loginUrl.searchParams.set("reason", "no-token");
loginUrl.searchParams.set("redirect", currentPath);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
// Allow the request to proceed if not protected or token exists // 2⃣ Validate + decode
const payload = await validateToken(token);
if (!payload) {
const res = NextResponse.redirect(loginUrl);
res.cookies.delete(COOKIE_NAME);
loginUrl.searchParams.set("reason", "invalid-token");
loginUrl.searchParams.set("redirect", currentPath);
return res;
}
// 3⃣ Expiry check
if (isExpired(payload.exp)) {
const res = NextResponse.redirect(loginUrl);
res.cookies.delete(COOKIE_NAME);
loginUrl.searchParams.set("reason", "expired-token");
loginUrl.searchParams.set("redirect", currentPath);
return res;
}
// 4⃣ Must change password check
if (payload.MustChangePassword) {
loginUrl.searchParams.set("reason", "change-password");
loginUrl.searchParams.set("redirect", currentPath);
return NextResponse.redirect(loginUrl);
}
// ✅ All good
return NextResponse.next(); return NextResponse.next();
} }
// Configure matcher to run middleware on specific paths
export const config = { export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*"], // Apply to dashboard and its sub-paths matcher: [
"/dashboard/:path*",
"/settings/:path*",
"/admin/:path*",
"/change-password/:path*",
],
}; };

View File

@ -86,19 +86,19 @@ export const handlers = [
if (userId) { if (userId) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.user.toString() === userId tx => tx.user.toString() === userId
); );
} }
if (state) { if (state) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.state.toLowerCase() === state.toLowerCase() tx => tx.state.toLowerCase() === state.toLowerCase()
); );
} }
if (statusCode) { if (statusCode) {
filteredTransactions = filteredTransactions.filter( filteredTransactions = filteredTransactions.filter(
(tx) => tx.pspStatusCode.toString() === statusCode tx => tx.pspStatusCode.toString() === statusCode
); );
} }

View File

@ -1,5 +1,5 @@
// mocks/server.ts // mocks/server.ts
import { setupServer } from 'msw/node'; import { setupServer } from "msw/node";
import { handlers } from './handlers'; import { handlers } from "./handlers";
export const server = setupServer(...handlers); export const server = setupServer(...handlers);

View File

@ -2,7 +2,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
webpack: (config) => { sassOptions: {
quietDeps: true,
silenceDeprecations: ["legacy-js-api"],
},
webpack: config => {
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") { if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
config.resolve.alias["@mswjs/interceptors"] = false; config.resolve.alias["@mswjs/interceptors"] = false;
} }

View File

@ -4,10 +4,19 @@
"private": true, "private": true,
"scripts": { "scripts": {
"msw-init": "msw init public/ --save", "msw-init": "msw init public/ --save",
"dev": "next dev --turbopack", "dev": "cross-env SASS_SILENCE_DEPRECATION_WARNINGS=1 next dev --turbopack -p 4000",
"dev:debug": "node --inspect-brk ./node_modules/next/dist/bin/next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"lint:fix": "next lint --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format:staged": "prettier --write",
"type-check": "tsc --noEmit",
"check-all": "npm run type-check && npm run lint && npm run format:check",
"fix-all": "npm run lint:fix && npm run format",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@ -26,8 +35,11 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-date-range": "^2.0.1", "react-date-range": "^2.0.1",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"redux-observable": "^3.0.0-rc.2",
"rxjs": "^7.8.2",
"sass": "^1.89.2", "sass": "^1.89.2",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
@ -39,9 +51,12 @@
"@types/react-date-range": "^1.4.10", "@types/react-date-range": "^1.4.10",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-redux": "^7.1.34", "@types/react-redux": "^7.1.34",
"@types/redux-persist": "^4.3.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.3", "eslint-config-next": "15.3.3",
"husky": "^9.1.7",
"msw": "^2.10.2", "msw": "^2.10.2",
"prettier": "^3.6.2",
"typescript": "^5" "typescript": "^5"
}, },
"msw": { "msw": {

View File

@ -1,7 +1,7 @@
import { IEditUserForm } from "@/app/features/UserRoles/User.interfaces"; import { IEditUserForm } from "@/app/features/UserRoles/User.interfaces";
export async function addUser(data: IEditUserForm) { export async function addUser(data: IEditUserForm) {
const res = await fetch("/api/dashboard/admin/users", { const res = await fetch("/api/auth/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),

View File

@ -1036,6 +1036,13 @@
dependencies: dependencies:
csstype "^3.0.2" csstype "^3.0.2"
"@types/redux-persist@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/redux-persist/-/redux-persist-4.3.1.tgz#aa4c876859e0bea5155e5f7980e5b8c4699dc2e6"
integrity sha512-YkMnMUk+4//wPtiSTMfsxST/F9Gh9sPWX0LVxHuOidGjojHtMdpep2cYvQgfiDMnj34orXyZI+QJCQMZDlafKA==
dependencies:
redux-persist "*"
"@types/statuses@^2.0.4": "@types/statuses@^2.0.4":
version "2.0.6" version "2.0.6"
resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz" resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz"
@ -2401,6 +2408,11 @@ globalthis@^1.0.4:
define-properties "^1.2.1" define-properties "^1.2.1"
gopd "^1.0.1" gopd "^1.0.1"
goober@^2.1.16:
version "2.1.18"
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.18.tgz#b72d669bd24d552d441638eee26dfd5716ea6442"
integrity sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==
gopd@^1.0.1, gopd@^1.2.0: gopd@^1.0.1, gopd@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz" resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
@ -2471,6 +2483,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1:
dependencies: dependencies:
react-is "^16.7.0" react-is "^16.7.0"
husky@^9.1.7:
version "9.1.7"
resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d"
integrity sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==
ignore@^5.2.0: ignore@^5.2.0:
version "5.3.2" version "5.3.2"
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
@ -3160,6 +3177,11 @@ prelude-ls@^1.2.1:
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@^3.6.2:
version "3.6.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393"
integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1" version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
@ -3208,6 +3230,14 @@ react-dom@^19.0.0:
dependencies: dependencies:
scheduler "^0.26.0" scheduler "^0.26.0"
react-hot-toast@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.6.0.tgz#4ada6ed3c75c5e42a90d562f55665ff37ee1442b"
integrity sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==
dependencies:
csstype "^3.1.3"
goober "^2.1.16"
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
@ -3286,6 +3316,16 @@ recharts@^2.15.3:
tiny-invariant "^1.3.1" tiny-invariant "^1.3.1"
victory-vendor "^36.6.8" victory-vendor "^36.6.8"
redux-observable@^3.0.0-rc.2:
version "3.0.0-rc.2"
resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-3.0.0-rc.2.tgz#baef603781c5dabd9ddd70526357076cd5c128a2"
integrity sha512-gG/pWIKgSrcTyyavm2so5tc7tuyCQ47p3VdCAG6wt+CV0WGhDr50cMQHLcYKxFZSGgTm19a8ZmyfJGndmGDpYg==
redux-persist@*:
version "6.0.0"
resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8"
integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==
redux-thunk@^3.1.0: redux-thunk@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz" resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz"
@ -3384,6 +3424,13 @@ run-parallel@^1.1.9:
dependencies: dependencies:
queue-microtask "^1.2.2" queue-microtask "^1.2.2"
rxjs@^7.8.2:
version "7.8.2"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b"
integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
dependencies:
tslib "^2.1.0"
safe-array-concat@^1.1.3: safe-array-concat@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz" resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz"
@ -3777,7 +3824,7 @@ tsconfig-paths@^3.15.0:
minimist "^1.2.6" minimist "^1.2.6"
strip-bom "^3.0.0" strip-bom "^3.0.0"
tslib@^2.4.0, tslib@^2.8.0: tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0:
version "2.8.1" version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==