diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..72c4429 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4ed4f2e --- /dev/null +++ b/.prettierignore @@ -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 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..67c0bc8 --- /dev/null +++ b/.prettierrc @@ -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" +} diff --git a/README.md b/README.md index c90f384..55d36c2 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ A modern backoffice admin panel built with [Next.js](https://nextjs.org/) and [R ## ๐Ÿ“ฆ Getting Started - ```bash git clone https://git.luckyigaming.com/Mitchell/payment-backoffice.git cd backoffice yarn run dev +``` diff --git a/app/AuthBootstrap.tsx b/app/AuthBootstrap.tsx new file mode 100644 index 0000000..bec1b62 --- /dev/null +++ b/app/AuthBootstrap.tsx @@ -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(); + const startedRef = useRef(false); + const intervalRef = useRef | 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; +} diff --git a/app/ReduxProvider.tsx b/app/ReduxProvider.tsx new file mode 100644 index 0000000..1a6ee24 --- /dev/null +++ b/app/ReduxProvider.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/api/auth/change-password/route.ts b/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..c21da7a --- /dev/null +++ b/app/api/auth/change-password/route.ts @@ -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 } + ); + } +} diff --git a/app/api/auth/login/route.tsx b/app/api/auth/login/route.tsx index 32aaf4e..ffbe0d2 100644 --- a/app/api/auth/login/route.tsx +++ b/app/api/auth/login/route.tsx @@ -1,64 +1,92 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; -import { SignJWT } from "jose"; +import { decodeJwt } from "jose"; -// Secret key for JWT signing (in production, use environment variable) -const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET); +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583"; +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) { try { const { email, password } = await request.json(); - // --- Replace with your ACTUAL authentication logic --- - // In a real application, you would: - // 1. Query your database for the user by email. - // 2. Hash the provided password and compare it to the stored hashed password. - // 3. If credentials match, generate a secure JWT (JSON Web Token) or session ID. - // 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 - }); + // Call backend login + const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!resp.ok) { + const errJson = await safeJson(resp); return NextResponse.json( - { success: true, message: "Login successful" }, - { status: 200 } - ); - } else { - return NextResponse.json( - { success: false, message: "Invalid credentials" }, - { status: 401 } + { success: false, message: errJson?.message || "Login failed" }, + { status: resp.status } ); } + + 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) { - console.error("Login API error:", error); + console.error("Login proxy error:", error); return NextResponse.json( { success: false, message: "Internal server error" }, { status: 500 } ); } } + +async function safeJson(resp: Response) { + try { + return await resp.json(); + } catch { + return null; + } +} diff --git a/app/api/auth/logout/route.tsx b/app/api/auth/logout/route.tsx index 075f76d..0ba3c09 100644 --- a/app/api/auth/logout/route.tsx +++ b/app/api/auth/logout/route.tsx @@ -1,24 +1,28 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; -// This is your DELETE handler for the logout endpoint -export async function DELETE() { - 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"); +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:3000"; +const COOKIE_NAME = "auth_token"; - return NextResponse.json( - { success: true, message: "Logged out successfully" }, - { status: 200 }, - ); - } catch (error) { - console.error("Logout API error:", error); - return NextResponse.json( - { success: false, message: "Internal server error during logout" }, - { status: 500 }, - ); +export async function DELETE() { + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (token) { + try { + await fetch(`${BE_BASE_URL}/logout`, { + method: "POST", + 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" }); } diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..8459229 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -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; + } +} diff --git a/app/api/auth/validate/route.ts b/app/api/auth/validate/route.ts new file mode 100644 index 0000000..3c406b9 --- /dev/null +++ b/app/api/auth/validate/route.ts @@ -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 } + ); + } +} diff --git a/app/api/dashboard/admin/users/[id]/route.ts b/app/api/dashboard/admin/users/[id]/route.ts index 5544bcd..40897fb 100644 --- a/app/api/dashboard/admin/users/[id]/route.ts +++ b/app/api/dashboard/admin/users/[id]/route.ts @@ -1,46 +1,45 @@ -import { NextRequest, NextResponse } from "next/server"; -import { users, type User } from "../../mockData"; // adjust relative path +// app/api/users/[id]/route.ts +import { NextResponse } from "next/server"; -type UpdateUserBody = Partial<{ - firstName: string; - lastName: string; - email: string; - phone: string; - role: string; -}>; +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } +export async function PATCH( + request: Request, + { params }: { params: { id: string } } ) { - const { id } = await params; - - let body: unknown; try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + console.log("[PATCH /users] - params", params); + const { id } = params; + 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 }); } diff --git a/app/api/dashboard/admin/users/route.ts b/app/api/dashboard/admin/users/route.ts deleted file mode 100644 index 3dedddb..0000000 --- a/app/api/dashboard/admin/users/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/dashboard/approve/route.ts b/app/api/dashboard/approve/route.ts index 440fa17..eae040f 100644 --- a/app/api/dashboard/approve/route.ts +++ b/app/api/dashboard/approve/route.ts @@ -7,8 +7,8 @@ export async function GET(request: NextRequest) { let filteredApproveRows = [...approveRows]; if (merchantId) { - filteredApproveRows = filteredApproveRows.filter((tx) => - tx.merchantId.toString().includes(merchantId), + filteredApproveRows = filteredApproveRows.filter(tx => + tx.merchantId.toString().includes(merchantId) ); } diff --git a/app/api/dashboard/audits/route.ts b/app/api/dashboard/audits/route.ts index adab442..afac8c9 100644 --- a/app/api/dashboard/audits/route.ts +++ b/app/api/dashboard/audits/route.ts @@ -16,23 +16,22 @@ export async function GET(request: NextRequest) { if (actionType) { filteredRows = filteredRows.filter( - (tx) => - tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase(), + tx => tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase() ); } if (affectedUserId) { filteredRows = filteredRows.filter( - (tx) => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase(), + tx => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase() ); } if (adminId) { - filteredRows = filteredRows.filter((tx) => tx.adminId === adminId); + filteredRows = filteredRows.filter(tx => tx.adminId === adminId); } if (adminUsername) { 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", }, - { status: 400 }, + { status: 400 } ); } - filteredRows = filteredRows.filter((tx) => { + filteredRows = filteredRows.filter(tx => { const txDate = new Date(tx.timeStampOfTheAction); // Validate if the timestamp is a valid date diff --git a/app/api/dashboard/transactions/all/mockData.ts b/app/api/dashboard/transactions/all/mockData.ts index 5997ea2..8f8b487 100644 --- a/app/api/dashboard/transactions/all/mockData.ts +++ b/app/api/dashboard/transactions/all/mockData.ts @@ -232,8 +232,7 @@ export const allTransactionsExtraColumns: GridColDef[] = [ { field: "fraudScore", headerName: "Fraud Score", width: 130 }, ]; -export const extraColumns = ["currency", "errorInfo", "fraudScore"] - +export const extraColumns = ["currency", "errorInfo", "fraudScore"]; export const allTransactionsSearchLabels = [ { label: "User", field: "userId", type: "text" }, diff --git a/app/api/dashboard/transactions/all/route.ts b/app/api/dashboard/transactions/all/route.ts index 0c41870..e6bf076 100644 --- a/app/api/dashboard/transactions/all/route.ts +++ b/app/api/dashboard/transactions/all/route.ts @@ -1,5 +1,10 @@ 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"; @@ -20,29 +25,29 @@ export async function GET(request: NextRequest) { if (userId) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.userId.toString() === userId, + tx => tx.userId.toString() === userId ); } if (status) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.status.toLowerCase() === status.toLowerCase(), + tx => tx.status.toLowerCase() === status.toLowerCase() ); } if (depositMethod) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase(), + tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase() ); } if (merchandId) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.merchandId.toString() === merchandId, + tx => tx.merchandId.toString() === merchandId ); } if (transactionId) { 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", }, - { status: 400 }, + { status: 400 } ); } - filteredTransactions = filteredTransactions.filter((tx) => { + filteredTransactions = filteredTransactions.filter(tx => { const txDate = new Date(tx.dateTime); if (isNaN(txDate.getTime())) { diff --git a/app/api/dashboard/transactions/deposits/route.ts b/app/api/dashboard/transactions/deposits/route.ts index 1b7aea1..818f3f8 100644 --- a/app/api/dashboard/transactions/deposits/route.ts +++ b/app/api/dashboard/transactions/deposits/route.ts @@ -24,29 +24,29 @@ export async function GET(request: NextRequest) { if (userId) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.userId.toString() === userId, + tx => tx.userId.toString() === userId ); } if (status) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.status.toLowerCase() === status.toLowerCase(), + tx => tx.status.toLowerCase() === status.toLowerCase() ); } if (depositMethod) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase(), + tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase() ); } if (merchandId) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.merchandId.toString() === merchandId, + tx => tx.merchandId.toString() === merchandId ); } if (transactionId) { 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", }, - { status: 400 }, + { status: 400 } ); } - filteredTransactions = filteredTransactions.filter((tx) => { + filteredTransactions = filteredTransactions.filter(tx => { const txDate = new Date(tx.dateTime); if (isNaN(txDate.getTime())) { diff --git a/app/api/dashboard/transactions/withdrawal/mockData.ts b/app/api/dashboard/transactions/withdrawal/mockData.ts index 538062a..48c8fc8 100644 --- a/app/api/dashboard/transactions/withdrawal/mockData.ts +++ b/app/api/dashboard/transactions/withdrawal/mockData.ts @@ -7,7 +7,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Error", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -26,7 +26,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Error", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -45,7 +45,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Completed", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -62,7 +62,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049136973, withdrawalMethod: "Bank Transfer", status: "Completed", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -79,7 +79,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Error", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -98,7 +98,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Error", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -117,7 +117,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Error", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -136,7 +136,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Card", status: "Pending", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -153,7 +153,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Inprogress", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -170,7 +170,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Error", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -189,7 +189,7 @@ export const withdrawalTransactionDummyData = [ transactionId: 1049131973, withdrawalMethod: "Bank Transfer", status: "Error", - options: [ + options: [ { value: "Pending", label: "Pending" }, { value: "Completed", label: "Completed" }, { value: "Inprogress", label: "Inprogress" }, @@ -209,7 +209,7 @@ export const withdrawalTransactionsColumns: GridColDef[] = [ { field: "transactionId", headerName: "Transaction ID", width: 130 }, { field: "withdrawalMethod", headerName: "Withdrawal Method", 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: "dateTime", headerName: "Date / Time", width: 130 }, { field: "errorInfo", headerName: "Error Info", width: 130 }, diff --git a/app/api/dashboard/transactions/withdrawal/route.ts b/app/api/dashboard/transactions/withdrawal/route.ts index 967dc5a..52c8d3c 100644 --- a/app/api/dashboard/transactions/withdrawal/route.ts +++ b/app/api/dashboard/transactions/withdrawal/route.ts @@ -19,13 +19,13 @@ export async function GET(request: NextRequest) { if (userId) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.userId.toString() === userId, + tx => tx.userId.toString() === userId ); } if (status) { 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", }, - { status: 400 }, + { status: 400 } ); } - filteredTransactions = filteredTransactions.filter((tx) => { + filteredTransactions = filteredTransactions.filter(tx => { const txDate = new Date(tx.dateTime); if (isNaN(txDate.getTime())) { @@ -55,8 +55,7 @@ export async function GET(request: NextRequest) { if (withdrawalMethod) { filteredTransactions = filteredTransactions.filter( - (tx) => - tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase(), + tx => tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase() ); } diff --git a/app/api/dashboard/transactionsHistory/mockData.ts b/app/api/dashboard/transactionsHistory/mockData.ts index 739dcf5..33a580b 100644 --- a/app/api/dashboard/transactionsHistory/mockData.ts +++ b/app/api/dashboard/transactionsHistory/mockData.ts @@ -1,65 +1,83 @@ - interface Transaction { - userId: number | string; +interface Transaction { + userId: number | string; date: string; method: string; amount: number | string; status: string; } - export const deposits: Transaction[] = [ - { userId: 17, date: "2025-08-01 10:10", method: "CC", amount: 120, status: "approved" }, - { - userId: 17, - date: "2025-07-28 14:35", - method: "Bank Transfer", - amount: 250, - status: "approved", - }, - { - userId: 17, - date: "2025-07-20 09:05", - method: "PayPal", - 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 deposits: Transaction[] = [ + { + userId: 17, + date: "2025-08-01 10:10", + method: "CC", + amount: 120, + status: "approved", + }, + { + userId: 17, + date: "2025-07-28 14:35", + method: "Bank Transfer", + amount: 250, + status: "approved", + }, + { + userId: 17, + date: "2025-07-20 09:05", + method: "PayPal", + 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[] = [ - { - userId: 17, - date: "2025-08-02 11:20", - method: "Crypto", - amount: 95, - status: "processing", - }, - { - userId: 17, - date: "2025-07-29 16:45", - method: "Bank Transfer", - amount: 220, - status: "approved", - }, - { - userId: 17, - date: "2025-07-21 15:10", - method: "eWallet", - amount: 60, - status: "pending", - }, - { userId: 17, - date: "2025-07-12 13:33", - method: "Crypto", - amount: 120, - status: "approved", - }, - { userId: 17, - date: "2025-07-03 08:50", - method: "Bank Transfer", - amount: 150, - status: "rejected", - }, - ]; \ No newline at end of file +export const withdrawals: Transaction[] = [ + { + userId: 17, + date: "2025-08-02 11:20", + method: "Crypto", + amount: 95, + status: "processing", + }, + { + userId: 17, + date: "2025-07-29 16:45", + method: "Bank Transfer", + amount: 220, + status: "approved", + }, + { + userId: 17, + date: "2025-07-21 15:10", + method: "eWallet", + amount: 60, + status: "pending", + }, + { + userId: 17, + date: "2025-07-12 13:33", + method: "Crypto", + amount: 120, + status: "approved", + }, + { + userId: 17, + date: "2025-07-03 08:50", + method: "Bank Transfer", + amount: 150, + status: "rejected", + }, +]; diff --git a/app/api/dashboard/transactionsHistory/route.ts b/app/api/dashboard/transactionsHistory/route.ts index 89c5980..c3712b4 100644 --- a/app/api/dashboard/transactionsHistory/route.ts +++ b/app/api/dashboard/transactionsHistory/route.ts @@ -7,12 +7,15 @@ export async function GET(request: NextRequest) { let filteredDeposits = [...deposits]; let filteredwithdrawals = [...withdrawals]; - if( userId ){ - filteredDeposits = filteredDeposits.filter((item) => item.userId.toString() === userId.toString()) - filteredwithdrawals = filteredwithdrawals.filter((item) => item.userId.toString() === userId.toString()) + if (userId) { + filteredDeposits = filteredDeposits.filter( + item => item.userId.toString() === userId.toString() + ); + filteredwithdrawals = filteredwithdrawals.filter( + item => item.userId.toString() === userId.toString() + ); } - return NextResponse.json({ deposits: filteredDeposits, withdrawals: filteredwithdrawals, diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/components/Modal/Modal.scss b/app/components/Modal/Modal.scss index 8dedc7f..3b9200e 100644 --- a/app/components/Modal/Modal.scss +++ b/app/components/Modal/Modal.scss @@ -17,7 +17,7 @@ box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2); position: relative; min-width: 320px; - max-width: 90vw; + max-width: 60vw; max-height: 90vh; overflow: auto; padding: 2rem 1.5rem 1.5rem 1.5rem; @@ -49,5 +49,5 @@ margin-top: 1rem; font-size: 1rem; color: #222; - width: 500px; + min-width: 450px; } diff --git a/app/components/Modal/Modal.tsx b/app/components/Modal/Modal.tsx index e0d25ee..0c820b3 100644 --- a/app/components/Modal/Modal.tsx +++ b/app/components/Modal/Modal.tsx @@ -27,7 +27,7 @@ const Modal: React.FC = ({ >
e.stopPropagation()} + onClick={e => e.stopPropagation()} data-testid="modal-content" > @@ -281,7 +280,7 @@ if (col.field === "userId") { justifyContent: "center", }, }} - onCellClick={(params) => { + onCellClick={params => { if (params.field !== "actions") { handleClickField(params.field, params.value as string); } @@ -305,7 +304,7 @@ if (col.field === "userId") { setState(e.target.value)} + onChange={e => setState(e.target.value)} label="Select state (defaults to All)" > Successful @@ -67,7 +67,7 @@ export const FetchReport = () => { Select PSPs (defaults to All) setReportType(e.target.value)} + onChange={e => setReportType(e.target.value)} label="Select report type" > diff --git a/app/features/GeneralHealthCard/GeneralHealthCard.scss b/app/features/GeneralHealthCard/GeneralHealthCard.scss index 389d05b..8ac0061 100644 --- a/app/features/GeneralHealthCard/GeneralHealthCard.scss +++ b/app/features/GeneralHealthCard/GeneralHealthCard.scss @@ -1,18 +1,18 @@ .general-health-card { - .general-health-card__header { - display: flex; - justify-content: space-between; - margin-bottom: 16px; + .general-health-card__header { + display: flex; + justify-content: space-between; + margin-bottom: 16px; - .general-health-card__right-side { - display: flex; - align-items: center; - } + .general-health-card__right-side { + display: flex; + align-items: center; } + } - .general-health-card__stat-items { - display: flex; - justify-content: space-around; - margin-top: 16px; - } + .general-health-card__stat-items { + display: flex; + justify-content: space-around; + margin-top: 16px; + } } diff --git a/app/features/GeneralHealthCard/components/StatItem.scss b/app/features/GeneralHealthCard/components/StatItem.scss index 71aaa1f..23a9921 100644 --- a/app/features/GeneralHealthCard/components/StatItem.scss +++ b/app/features/GeneralHealthCard/components/StatItem.scss @@ -1,12 +1,12 @@ .static-item { - text-align: center; - padding-left: 16px; - padding-right: 16px; + text-align: center; + padding-left: 16px; + padding-right: 16px; - .static-item__percentage { - display: flex; - align-items: center; - justify-content: center; - color: red; - } + .static-item__percentage { + display: flex; + align-items: center; + justify-content: center; + color: red; + } } diff --git a/app/features/Pages/Approve/Approve.tsx b/app/features/Pages/Approve/Approve.tsx index 3597dc1..f031973 100644 --- a/app/features/Pages/Approve/Approve.tsx +++ b/app/features/Pages/Approve/Approve.tsx @@ -92,7 +92,7 @@ export function ApproveTable({ router.replace(`?${params.toString()}`, { scroll: false }); }, 400), - [router, searchParams, searchParamKey], + [router, searchParams, searchParamKey] ); const handleSearchChange = (e: React.ChangeEvent) => { @@ -102,13 +102,11 @@ export function ApproveTable({ }; const handleCheckboxChange = (id: string | number, checked: boolean) => { - setSelected((prev) => - checked ? [...prev, id] : prev.filter((x) => x !== id), - ); + setSelected(prev => (checked ? [...prev, id] : prev.filter(x => x !== id))); }; const handleToggleAll = (checked: boolean) => { - setSelected(checked ? rows.map((r) => r.id) : []); + setSelected(checked ? rows.map(r => r.id) : []); }; const handleActionChange = (e: SelectChangeEvent) => { @@ -125,7 +123,7 @@ export function ApproveTable({ const handleStatusSave = () => { 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); setReason(""); @@ -177,7 +175,7 @@ export function ApproveTable({ onChange={handleActionChange} size="small" > - {actions.map((item) => ( + {actions.map(item => ( {item.label ?? item.value} @@ -219,7 +217,7 @@ export function ApproveTable({ indeterminate={ selected.length > 0 && selected.length < rows.length } - onChange={(e) => handleToggleAll(e.target.checked)} + onChange={e => handleToggleAll(e.target.checked)} /> @@ -257,7 +255,7 @@ export function ApproveTable({ > + onChange={e => handleCheckboxChange(row.id, e.target.checked) } /> @@ -304,7 +302,9 @@ export function ApproveTable({ width: "100%", }} > - {value as React.ReactNode} + + {value as React.ReactNode} + { + const [submitting, setSubmitting] = useState(false); + const user = useSelector((state: RootState) => state.auth.user); + const dispatch = useDispatch(); + + 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 ( + + + Account Security + + {}} + onSubmit={handleChangePassword} + /> + + ); +}; + +export default SettingsAccountSecurity; diff --git a/app/features/Pages/Settings/SettingsPageClient.tsx b/app/features/Pages/Settings/SettingsPageClient.tsx new file mode 100644 index 0000000..b3438b6 --- /dev/null +++ b/app/features/Pages/Settings/SettingsPageClient.tsx @@ -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 ( + + + Settings + + + + "personal" | "account") + ) => setActiveSection(section)} + /> + + + {activeSection === "personal" && } + {activeSection === "account" && } + + + + ); +}; + +export default SettingsPageClient; diff --git a/app/features/Pages/Settings/SettingsPersonalInfo.tsx b/app/features/Pages/Settings/SettingsPersonalInfo.tsx new file mode 100644 index 0000000..89f4c8d --- /dev/null +++ b/app/features/Pages/Settings/SettingsPersonalInfo.tsx @@ -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(); + + 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) => { + 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 ( + + + Personal Info + + + + + + + + + + + + ); +}; + +export default SettingsPersonalInfo; diff --git a/app/features/Pages/Settings/SettingsSidebar.tsx b/app/features/Pages/Settings/SettingsSidebar.tsx new file mode 100644 index 0000000..eb20149 --- /dev/null +++ b/app/features/Pages/Settings/SettingsSidebar.tsx @@ -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 = ({ active, onChange }) => { + return ( + + + onChange("personal")} + > + + + + + + onChange("account")} + > + + + + + + + + ); +}; + +export default SettingsSidebar; diff --git a/app/features/PieCharts/PieCharts.scss b/app/features/PieCharts/PieCharts.scss index aa280e7..60c70f6 100644 --- a/app/features/PieCharts/PieCharts.scss +++ b/app/features/PieCharts/PieCharts.scss @@ -1,8 +1,8 @@ .pie-charts { - width: 100%; - height: 300px; + width: 100%; + height: 300px; - @media (min-width: 960px) { - width: 60%; - } + @media (min-width: 960px) { + width: 60%; + } } diff --git a/app/features/SectionCard/SectionCard.scss b/app/features/SectionCard/SectionCard.scss index fd94cd7..eb05eed 100644 --- a/app/features/SectionCard/SectionCard.scss +++ b/app/features/SectionCard/SectionCard.scss @@ -1,20 +1,20 @@ .section-card { - padding: 16px; - margin: 16px; + padding: 16px; + margin: 16px; + display: flex; + flex-direction: column; + .section-card__header { display: flex; - flex-direction: column; - .section-card__header { - display: flex; - justify-content: space-between; - margin-bottom: 16px; - .section-card__title { - display: flex; - gap: 16px; - } - .section-card__icon-wrapper { - display: flex; - align-items: center; - gap: 16px; - } + justify-content: space-between; + margin-bottom: 16px; + .section-card__title { + display: flex; + gap: 16px; } + .section-card__icon-wrapper { + display: flex; + align-items: center; + gap: 16px; + } + } } diff --git a/app/features/TransactionsOverview/TransactionsOverView.scss b/app/features/TransactionsOverview/TransactionsOverView.scss index 9494eec..67c491a 100644 --- a/app/features/TransactionsOverview/TransactionsOverView.scss +++ b/app/features/TransactionsOverview/TransactionsOverView.scss @@ -1,28 +1,28 @@ .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; display: flex; - flex-direction: column; + flex-direction: row; + gap: 32px; + flex-wrap: wrap; - .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; - display: flex; - flex-direction: row; - gap: 32px; - flex-wrap: wrap; - - @media (min-width: 960px) { - flex-wrap: nowrap; - gap: 0; - } + @media (min-width: 960px) { + flex-wrap: nowrap; + gap: 0; } + } } diff --git a/app/features/TransactionsOverview/components/TransactionsOverViewTable.scss b/app/features/TransactionsOverview/components/TransactionsOverViewTable.scss index b58bd2b..fbb9d21 100644 --- a/app/features/TransactionsOverview/components/TransactionsOverViewTable.scss +++ b/app/features/TransactionsOverview/components/TransactionsOverViewTable.scss @@ -1,17 +1,17 @@ .transactions-overview-table { - .transactions-overview-table__state-wrapper { - display: flex; - justify-content: flex-start; - align-items: center; - margin-left: auto; - margin-right: auto; - width: 73px; + .transactions-overview-table__state-wrapper { + display: flex; + justify-content: flex-start; + align-items: center; + margin-left: auto; + margin-right: auto; + width: 73px; - .transactions-overview-table__state { - width: 10px; - height: 10px; - border-radius: 50%; - margin-right: 8px; - } + .transactions-overview-table__state { + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 8px; } + } } diff --git a/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.scss b/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.scss index d59b128..c2ce3f2 100644 --- a/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.scss +++ b/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.scss @@ -1,6 +1,6 @@ .transactions-waiting-approval { - padding: 16px; - margin: 16px; - display: flex; - flex-direction: column; + padding: 16px; + margin: 16px; + display: flex; + flex-direction: column; } diff --git a/app/features/UserRoles/AddUser/AddUser.scss b/app/features/UserRoles/AddUser/AddUser.scss index a66016a..e32c364 100644 --- a/app/features/UserRoles/AddUser/AddUser.scss +++ b/app/features/UserRoles/AddUser/AddUser.scss @@ -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); + } + } + } +} diff --git a/app/features/UserRoles/AddUser/AddUser.tsx b/app/features/UserRoles/AddUser/AddUser.tsx index e3b3531..84e6b30 100644 --- a/app/features/UserRoles/AddUser/AddUser.tsx +++ b/app/features/UserRoles/AddUser/AddUser.tsx @@ -5,6 +5,8 @@ import { useRouter } from "next/navigation"; import "./AddUser.scss"; import { addUser } from "@/services/roles.services"; import { IEditUserForm } from "../User.interfaces"; +import { COUNTRY_CODES } from "../constants"; +import { formatPhoneDisplay, validatePhone } from "../utils"; interface AddUserFormProps { onSuccess?: () => void; @@ -13,27 +15,81 @@ interface AddUserFormProps { const AddUserForm: React.FC = ({ onSuccess }) => { const router = useRouter(); const [form, setForm] = useState({ + username: "", firstName: "", lastName: "", email: "", phone: "", role: "", + merchants: [], + groups: [], + jobTitle: "", }); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [phoneError, setPhoneError] = useState(""); + const [countryCode, setCountryCode] = useState("+1"); const handleChange = ( e: React.ChangeEvent ) => { 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) => { + 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) => { 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."); return; } @@ -42,7 +98,13 @@ const AddUserForm: React.FC = ({ onSuccess }) => { setLoading(true); 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(); router.refresh(); // <- refreshes the page (SSR re-runs) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -55,6 +117,13 @@ const AddUserForm: React.FC = ({ onSuccess }) => { return (
+ = ({ onSuccess }) => { onChange={handleChange} required /> - +
+ + + {form.merchants.length > 0 && ( +
+ {form.merchants.map((merchant, index) => ( + + {merchant} + + + ))} +
+ )} +
+ +
+ + + {form.groups.length > 0 && ( +
+ {form.groups.map((group, index) => ( + + {group} + + + ))} +
+ )} +
+
+ + +
- {error &&
{error}
} + {error && {error}} + + {phoneError} +
- {PAGE_LINKS.map((link) => + {PAGE_LINKS.map(link => link.children ? (
{openMenus[link.title] && (
- {link.children.map((child) => ( + {link.children.map(child => ( { - // Re-dispatch checkAuthStatus to get updated expiration time - // This will be handled by the ReduxProvider - }, 5 * 60 * 1000); // 5 minutes + const checkInterval = setInterval( + () => { + // Re-dispatch checkAuthStatus to get updated expiration time + // This will be handled by the ReduxProvider + }, + 5 * 60 * 1000 + ); // 5 minutes return () => { if (timeoutRef.current) { diff --git a/app/layout.tsx b/app/layout.tsx index b1a7b8e..eb2891a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,10 @@ -import ThemeRegistry from "@/config/ThemeRegistry"; +// app/layout.tsx 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 { InitializeAuth } from "./redux/InitializeAuth"; export const metadata: Metadata = { title: "Your App", @@ -18,8 +20,10 @@ export default function RootLayout({ - + {/* Bootstraps session validation + redirect if invalid */} + {children} + diff --git a/app/login/LoginPageClient.tsx b/app/login/LoginPageClient.tsx index 1ffd414..68d31d1 100644 --- a/app/login/LoginPageClient.tsx +++ b/app/login/LoginPageClient.tsx @@ -10,9 +10,15 @@ import { selectAuthMessage, selectIsLoggedIn, selectStatus, + selectUser, } from "../redux/auth/selectors"; -import { clearAuthMessage, login } from "../redux/auth/authSlice"; +import { + clearAuthMessage, + login, + changePassword, +} from "../redux/auth/authSlice"; import "./page.scss"; +import { ChangePassword } from "../features/Auth/ChangePassword/ChangePassword"; export default function LoginPageClient() { const router = useRouter(); @@ -22,8 +28,14 @@ export default function LoginPageClient() { const isLoggedIn = useSelector(selectIsLoggedIn); const status = useSelector(selectStatus); const authMessage = useSelector(selectAuthMessage); + const user = useSelector(selectUser); const dispatch = useDispatch(); const [redirectMessage, setRedirectMessage] = useState(""); + const [mustChangePassword, setMustChangePassword] = useState( + reason === "change-password" + ); + + console.log("[DEBUG] [reason]:", reason === "change-password"); useEffect(() => { // Set message based on redirect reason @@ -33,14 +45,22 @@ export default function LoginPageClient() { setRedirectMessage("Invalid session detected. Please log in again."); } else if (reason === "no-token") { setRedirectMessage("Please log in to access the backoffice."); + } else if (reason === "change-password") { + setRedirectMessage( + "Please change your password to access the backoffice." + ); } }, [reason]); useEffect(() => { - if (isLoggedIn && status === "succeeded") { + setMustChangePassword(reason === "change-password" ? true : false); + }, [reason]); + + useEffect(() => { + if (isLoggedIn && status === "succeeded" && !mustChangePassword) { router.replace(redirectPath); } - }, [isLoggedIn, status, router, redirectPath]); + }, [isLoggedIn, status, mustChangePassword, router, redirectPath]); const handleLogin = async (email: string, password: string) => { const resultAction = await dispatch(login({ email, password })); @@ -51,7 +71,31 @@ export default function LoginPageClient() { 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 (
@@ -66,13 +110,23 @@ export default function LoginPageClient() { return (
- - - + {mustChangePassword ? ( + + {}} + onSubmit={handleChangePassword} + /> + + ) : ( + + + + )}
); } diff --git a/app/login/page.scss b/app/login/page.scss index 2e32ae3..0012bb5 100644 --- a/app/login/page.scss +++ b/app/login/page.scss @@ -1,3 +1,5 @@ +@use "sass:color"; + // Variables for consistent styling $primary-color: #2563eb; // Blue-600 equivalent $primary-hover-color: #1d4ed8; // Blue-700 equivalent @@ -26,7 +28,8 @@ $bg-color-white: #ffffff; 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), + 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; @@ -59,6 +62,9 @@ $bg-color-white: #ffffff; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md transition: background-color 0.3s ease-in-out; &:hover { - background-color: darken($error-color, 5%); // red-700 equivalent + background-color: color.adjust( + $error-color, + $lightness: -5% + ); // red-700 equivalent } } diff --git a/app/redux/ReduxProvider.tsx b/app/redux/ReduxProvider.tsx index 5b99945..369d925 100644 --- a/app/redux/ReduxProvider.tsx +++ b/app/redux/ReduxProvider.tsx @@ -24,9 +24,12 @@ export default function ReduxProvider({ }, 2000); // Set up periodic token validation every 5 minutes - intervalRef.current = setInterval(() => { - store.dispatch(checkAuthStatus()); - }, 5 * 60 * 1000); // 5 minutes + intervalRef.current = setInterval( + () => { + store.dispatch(checkAuthStatus()); + }, + 5 * 60 * 1000 + ); // 5 minutes return () => { if (intervalRef.current) { diff --git a/app/redux/auth/authSlice.tsx b/app/redux/auth/authSlice.tsx index fe19431..cc60210 100644 --- a/app/redux/auth/authSlice.tsx +++ b/app/redux/auth/authSlice.tsx @@ -1,20 +1,21 @@ 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 { isLoggedIn: boolean; authMessage: string; status: "idle" | "loading" | "succeeded" | "failed"; error: string | null; - user: { - email: string; - role: string; - } | null; - tokenInfo: { - expiresAt: string; - timeUntilExpiration: number; - expiresInHours: number; - } | null; + user: IUserResponse | null; + tokenInfo: TokenInfo | null; + mustChangePassword: boolean; } const initialState: AuthState = { @@ -24,318 +25,295 @@ const initialState: AuthState = { error: null, user: null, tokenInfo: null, + mustChangePassword: false, }; -// Async Thunk for Login -// This handles the API call to your Next.js login Route Handler -export const login = createAsyncThunk( +// ---------------- Login ---------------- +export const login = createAsyncThunk< + ThunkSuccess<{ + user: IUserResponse | null; + tokenInfo: TokenInfo | null; + mustChangePassword: boolean; + }>, + { email: string; password: string }, + { rejectValue: ThunkError } +>( "auth/login", async ( { email, password }: { email: string; password: string }, { rejectWithValue } ) => { try { - const response = await fetch("/api/auth/login", { + const res = await fetch("/api/auth/login", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); - const data = await response.json(); - - if (!response.ok) { - // If the server responded with an error status (e.g., 401, 400, 500) - return rejectWithValue(data.message || "Login failed"); + const data = await res.json(); + if (!res.ok) { + toast.error(data.message || "Login failed"); + return rejectWithValue((data.message as string) || "Login failed"); } - // On successful login, the backend sets the HTTP-only cookie. - // We'll set a client-side flag (like localStorage) for immediate UI updates, - // though the primary source of truth for auth is the HTTP-only cookie. - if (typeof window !== "undefined") { - // Ensure localStorage access is client-side - localStorage.setItem("userToken", "mock-authenticated"); // For client-side state sync - } + // After login, validate session right away + const validateRes = await fetch("/api/auth/validate", { method: "POST" }); + const validateData = await validateRes.json(); - // After successful login, check auth status to get token information - // 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, - }; - } + toast.success(data.message || "Login successful"); - return data.message || "Login successful"; - } catch (error: unknown) { - // Handle network errors or other unexpected issues - const errorMessage = - error instanceof Error ? error.message : "Network error during login"; - return rejectWithValue(errorMessage); + return { + message: data.message || "Login successful", + user: data.user || null, + tokenInfo: validateData.tokenInfo || null, + mustChangePassword: data.must_change_password || false, + } 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 -// This handles the API call to your Next.js logout Route Handler -export const logout = createAsyncThunk( - "auth/logout", - async (_, { rejectWithValue }) => { +// ---------------- Logout ---------------- +export const logout = createAsyncThunk< + ThunkSuccess, + void, + { 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 { - const response = await fetch("/api/auth/logout", { - method: "DELETE", + const res = await fetch("/api/auth/change-password", { + 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) { - // If the server responded with an error status - return rejectWithValue(data.message || "Logout failed"); - } - - if (typeof window !== "undefined") { - // Ensure localStorage access is client-side - 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); + return { + success: data.success, + message: data.message || "Password changed successfully", + }; + } catch (err: unknown) { + return rejectWithValue( + (err as Error).message || "Network error during password change" + ); } } ); -// Async Thunk for automatic logout (when token expires) -export const autoLogout = createAsyncThunk( - "auth/autoLogout", - async (reason: string, { rejectWithValue }) => { - try { - // Clear the cookie by calling logout endpoint - await fetch("/api/auth/logout", { - method: "DELETE", - }); +// ---------------- Unified Validate Auth ---------------- +export const validateAuth = createAsyncThunk< + { tokenInfo: TokenInfo | null }, + void, + { rejectValue: ThunkError } +>("auth/validate", async (_, { rejectWithValue }) => { + try { + const res = await fetch("/api/auth/validate", { method: "POST" }); + const data = await res.json(); - if (typeof window !== "undefined") { - // Clear client-side storage - localStorage.removeItem("userToken"); - } - - return { message: `Auto-logout: ${reason}` }; - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : "Auto-logout failed"; - return rejectWithValue(errorMessage); + if (!res.ok || !data.valid) { + return rejectWithValue("Session invalid or expired"); } + + 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 -export const refreshAuthStatus = createAsyncThunk( - "auth/refreshStatus", - async (_, { rejectWithValue }) => { - try { - const response = await fetch("/api/auth/status"); - const data = await response.json(); +// TODO - Creaye a new thunk to update the user stuff - if (!response.ok) { - return rejectWithValue(data.message || "Authentication refresh failed"); - } +// ---------------- Update User Details ---------------- +export const updateUserDetails = createAsyncThunk< + ThunkSuccess<{ user: IUserResponse }>, + { id: string; updates: Partial }, + { 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; - } catch (error: unknown) { - const errorMessage = - error instanceof Error - ? error.message - : "Network error during auth refresh"; - return rejectWithValue(errorMessage); + const data = await res.json(); + + if (!res.ok) { + return rejectWithValue(data.message || "Failed to update user details"); } + + 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 -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 +// ---------------- Slice ---------------- const authSlice = createSlice({ name: "auth", initialState, reducers: { - // Reducer to set an authentication message (e.g., from UI actions) setAuthMessage: (state, action: PayloadAction) => { state.authMessage = action.payload; }, - // Reducer to clear the authentication message - clearAuthMessage: (state) => { + clearAuthMessage: state => { 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 - // Login Thunk Reducers - .addCase(login.pending, (state) => { + // Login + .addCase(login.pending, state => { state.status = "loading"; - state.error = null; state.authMessage = "Attempting login..."; }) .addCase(login.fulfilled, (state, action) => { state.status = "succeeded"; state.isLoggedIn = true; - // Handle both old string payload and new object payload - if (typeof action.payload === "string") { - state.authMessage = action.payload; - } else { - state.authMessage = action.payload.message; - state.user = action.payload.user || null; - state.tokenInfo = action.payload.tokenInfo || null; - } + state.authMessage = action.payload.message; + state.user = action.payload.user; + state.tokenInfo = action.payload.tokenInfo; + state.mustChangePassword = action.payload.mustChangePassword; }) .addCase(login.rejected, (state, action) => { state.status = "failed"; state.isLoggedIn = false; state.error = action.payload as string; - state.authMessage = action.payload as string; // Display error message - }) - // Logout Thunk Reducers - .addCase(logout.pending, (state) => { - state.status = "loading"; - state.error = null; - state.authMessage = "Logging out..."; + state.authMessage = action.payload as string; }) + // Logout .addCase(logout.fulfilled, (state, action) => { state.status = "succeeded"; state.isLoggedIn = false; - // Handle both old string payload and new object payload - 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.authMessage = action.payload.message; state.user = null; state.tokenInfo = null; + state.mustChangePassword = false; }) .addCase(logout.rejected, (state, action) => { state.status = "failed"; - state.isLoggedIn = true; // Stay logged in if logout failed state.error = action.payload as string; - state.authMessage = action.payload as string; // Display error message - }) - // 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..."; + state.authMessage = action.payload as string; }) + // Auto Logout .addCase(autoLogout.fulfilled, (state, action) => { state.status = "succeeded"; state.isLoggedIn = false; state.authMessage = action.payload.message; state.user = null; state.tokenInfo = null; - state.error = null; + state.mustChangePassword = false; }) .addCase(autoLogout.rejected, (state, action) => { state.status = "failed"; - state.isLoggedIn = false; - state.user = null; - state.tokenInfo = null; state.error = action.payload as string; - state.authMessage = "Auto-logout failed"; }) - // Refresh Auth Status Thunk Reducers - .addCase(refreshAuthStatus.pending, (state) => { + // Change Password + .addCase(changePassword.pending, state => { state.status = "loading"; - state.error = null; + state.authMessage = "Changing password..."; }) - .addCase(refreshAuthStatus.fulfilled, (state, action) => { + .addCase(changePassword.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; + state.authMessage = action.payload.message; + state.mustChangePassword = action.payload.success; }) - .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.isLoggedIn = false; - state.user = null; state.tokenInfo = null; 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 } = - authSlice.actions; - +export const { setAuthMessage, clearAuthMessage } = authSlice.actions; export default authSlice.reducer; diff --git a/app/redux/auth/epic.ts b/app/redux/auth/epic.ts new file mode 100644 index 0000000..a72ba80 --- /dev/null +++ b/app/redux/auth/epic.ts @@ -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; diff --git a/app/redux/auth/selectors.ts b/app/redux/auth/selectors.ts index 8cac5bf..abeb76d 100644 --- a/app/redux/auth/selectors.ts +++ b/app/redux/auth/selectors.ts @@ -6,6 +6,8 @@ export const selectError = (state: RootState) => state.auth?.error; export const selectAuthMessage = (state: RootState) => state.auth?.authMessage; export const selectUser = (state: RootState) => state.auth?.user; export const selectTokenInfo = (state: RootState) => state.auth?.tokenInfo; +export const selectMustChangePassword = (state: RootState) => + state.auth?.mustChangePassword; export const selectTimeUntilExpiration = (state: RootState) => state.auth?.tokenInfo?.timeUntilExpiration || 0; export const selectExpiresInHours = (state: RootState) => diff --git a/app/redux/store.ts b/app/redux/store.ts index e08f42b..9ad3118 100644 --- a/app/redux/store.ts +++ b/app/redux/store.ts @@ -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 authReducer from "./auth/authSlice"; +import userEpics from "./user/epic"; +import authEpics from "./auth/epic"; -export const makeStore = () => { - return configureStore({ - reducer: { - advancedSearch: advancedSearchReducer, - auth: authReducer, - }, - // Enable Redux DevTools - devTools: process.env.NODE_ENV !== "production", - }); +type PersistedAuth = { user: unknown | null }; + +const persistConfig = { + key: "root", + storage, + whitelist: ["auth"], // only persist auth slice + transforms: [ + // 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 persistor = persistStore(store); + +// Types +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/app/redux/types.ts b/app/redux/types.ts index 574261a..473a43a 100644 --- a/app/redux/types.ts +++ b/app/redux/types.ts @@ -3,3 +3,42 @@ import { store } from "./store"; export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; +// Generic helper types for Redux thunks that return a user-facing message +export type ThunkSuccess = { 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; +} diff --git a/app/redux/user/epic.ts b/app/redux/user/epic.ts new file mode 100644 index 0000000..4fda5a8 --- /dev/null +++ b/app/redux/user/epic.ts @@ -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 = 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; diff --git a/app/services/approve.ts b/app/services/approve.ts index f04a0b7..8b50302 100644 --- a/app/services/approve.ts +++ b/app/services/approve.ts @@ -1,13 +1,9 @@ -export async function getApproves({ - query, -}: { - query: string; -}) { +export async function getApproves({ query }: { query: string }) { const res = await fetch( - `http://localhost:3000/api/dashboard/approve?${query}`, + `http://localhost:4000/api/dashboard/approve?${query}`, { cache: "no-store", - }, + } ); if (!res.ok) { diff --git a/app/services/audits.ts b/app/services/audits.ts index ac5e700..ff8a81b 100644 --- a/app/services/audits.ts +++ b/app/services/audits.ts @@ -1,13 +1,9 @@ -export async function getAudits({ - query, -}: { - query: string; -}) { +export async function getAudits({ query }: { query: string }) { const res = await fetch( - `http://localhost:3000/api/dashboard/audits?${query}`, + `http://localhost:4000/api/dashboard/audits?${query}`, { cache: "no-store", - }, + } ); if (!res.ok) { diff --git a/app/services/transactions.ts b/app/services/transactions.ts index 0d7f83e..9f91d1b 100644 --- a/app/services/transactions.ts +++ b/app/services/transactions.ts @@ -5,12 +5,11 @@ export async function getTransactions({ transactionType: string; query: string; }) { - const res = await fetch( - `http://localhost:3000/api/dashboard/transactions/${transactionType}?${query}`, + `http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`, { cache: "no-store", - }, + } ); if (!res.ok) { diff --git a/app/services/transactionsHistory.ts b/app/services/transactionsHistory.ts index 0c5e41e..87e305c 100644 --- a/app/services/transactionsHistory.ts +++ b/app/services/transactionsHistory.ts @@ -1,17 +1,12 @@ -export async function getTransactionsHistory({ - query, -}: { - query: string; -}) { - const baseUrl = - process.env.NEXT_PUBLIC_BASE_URL - ? `${process.env.NEXT_PUBLIC_BASE_URL}` - : "http://localhost:3000"; +export async function getTransactionsHistory({ query }: { query: string }) { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL + ? `${process.env.NEXT_PUBLIC_BASE_URL}` + : "http://localhost:4000"; const res = await fetch( `${baseUrl}/api/dashboard/transactionsHistory?${query}`, { cache: "no-store", - }, + } ); if (!res.ok) { diff --git a/app/utils/auth.ts b/app/utils/auth.ts index 9863027..452582a 100644 --- a/app/utils/auth.ts +++ b/app/utils/auth.ts @@ -1,7 +1,8 @@ import { jwtVerify } from "jose"; // 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 { email: string; @@ -15,7 +16,10 @@ export interface JWTPayload { */ export async function validateToken(token: string): Promise { 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; } catch (error) { console.error("Token validation error:", error); @@ -45,3 +49,19 @@ export function getTimeUntilExpiration(payload: JWTPayload): number { const currentTime = Math.floor(Date.now() / 1000); 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, + }; +} diff --git a/app/utils/exportData.ts b/app/utils/exportData.ts index 0bf1654..6e044b8 100644 --- a/app/utils/exportData.ts +++ b/app/utils/exportData.ts @@ -8,12 +8,14 @@ export const exportData = ( columns: TColumn[], fileType: FileType = "csv", onlyCurrentTable = false, - setOpen: (open: boolean) => void, + setOpen: (open: boolean) => void ) => { const exportRows = onlyCurrentTable ? rows.slice(0, 5) : rows; const exportData = [ - columns.map((col) => col.headerName), - ...exportRows.map((row) => columns.map((col) => (row as Record)[col.field] ?? "")), + columns.map(col => col.headerName), + ...exportRows.map(row => + columns.map(col => (row as Record)[col.field] ?? "") + ), ]; const worksheet = XLSX.utils.aoa_to_sheet(exportData); diff --git a/app/utils/formatDate.ts b/app/utils/formatDate.ts index 93c2071..6fb9ff9 100644 --- a/app/utils/formatDate.ts +++ b/app/utils/formatDate.ts @@ -2,12 +2,12 @@ export const formatToDateTimeString = (dateString: string): string => { const date = new Date(dateString); const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); // months are 0-indexed - const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); // months are 0-indexed + const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; -} \ No newline at end of file +}; diff --git a/middleware.ts b/middleware.ts index 99ed90d..59b2265 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,30 +1,81 @@ -// middleware.ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { jwtVerify } from "jose"; -export function middleware(request: NextRequest) { - const token = request.cookies.get("auth_token")?.value; // Get token from cookie +const COOKIE_NAME = "auth_token"; +const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!); - // Define protected paths - const protectedPaths = ["/dashboard", "/settings", "/admin"]; - const isProtected = protectedPaths.some((path) => - request.nextUrl.pathname.startsWith(path) - ); +function isExpired(exp?: number) { + return exp ? exp * 1000 <= Date.now() : false; +} - // If accessing a protected path and no token - if (isProtected && !token) { - // Redirect to login page - const loginUrl = new URL("/login", request.url); - // Optional: Add a redirect query param to return to original page after login - loginUrl.searchParams.set("redirect", request.nextUrl.pathname); +async function validateToken(token: string) { + const raw = token.startsWith("Bearer ") ? token.slice(7) : token; + + try { + const { payload } = await jwtVerify(raw, JWT_SECRET, { + 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); } - // 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(); } -// Configure matcher to run middleware on specific paths 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*", + ], }; diff --git a/mock/handlers.ts b/mock/handlers.ts index f949d9b..8e76168 100644 --- a/mock/handlers.ts +++ b/mock/handlers.ts @@ -86,19 +86,19 @@ export const handlers = [ if (userId) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.user.toString() === userId + tx => tx.user.toString() === userId ); } if (state) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.state.toLowerCase() === state.toLowerCase() + tx => tx.state.toLowerCase() === state.toLowerCase() ); } if (statusCode) { filteredTransactions = filteredTransactions.filter( - (tx) => tx.pspStatusCode.toString() === statusCode + tx => tx.pspStatusCode.toString() === statusCode ); } diff --git a/mock/server.ts b/mock/server.ts index 719bd8c..7f70d9c 100644 --- a/mock/server.ts +++ b/mock/server.ts @@ -1,5 +1,5 @@ // mocks/server.ts -import { setupServer } from 'msw/node'; -import { handlers } from './handlers'; +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; export const server = setupServer(...handlers); diff --git a/next.config.ts b/next.config.ts index ff819d9..d69ee95 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ - webpack: (config) => { + sassOptions: { + quietDeps: true, + silenceDeprecations: ["legacy-js-api"], + }, + webpack: config => { if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") { config.resolve.alias["@mswjs/interceptors"] = false; } diff --git a/package.json b/package.json index 363e708..2f612fa 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,19 @@ "private": true, "scripts": { "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", "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": { "@emotion/react": "^11.14.0", @@ -26,8 +35,11 @@ "react": "^19.0.0", "react-date-range": "^2.0.1", "react-dom": "^19.0.0", + "react-hot-toast": "^2.6.0", "react-redux": "^9.2.0", "recharts": "^2.15.3", + "redux-observable": "^3.0.0-rc.2", + "rxjs": "^7.8.2", "sass": "^1.89.2", "xlsx": "^0.18.5" }, @@ -39,9 +51,12 @@ "@types/react-date-range": "^1.4.10", "@types/react-dom": "^19", "@types/react-redux": "^7.1.34", + "@types/redux-persist": "^4.3.1", "eslint": "^9", "eslint-config-next": "15.3.3", + "husky": "^9.1.7", "msw": "^2.10.2", + "prettier": "^3.6.2", "typescript": "^5" }, "msw": { diff --git a/services/roles.services.ts b/services/roles.services.ts index f134a9a..f6cf334 100644 --- a/services/roles.services.ts +++ b/services/roles.services.ts @@ -1,7 +1,7 @@ import { IEditUserForm } from "@/app/features/UserRoles/User.interfaces"; export async function addUser(data: IEditUserForm) { - const res = await fetch("/api/dashboard/admin/users", { + const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), diff --git a/yarn.lock b/yarn.lock index 3f0a9f1..9c4aaf1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1036,6 +1036,13 @@ dependencies: 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": version "2.0.6" 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" 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: version "1.2.0" 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: 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: version "5.3.2" 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" 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: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" @@ -3208,6 +3230,14 @@ react-dom@^19.0.0: dependencies: 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: version "16.13.1" 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" 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: version "3.1.0" resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz" @@ -3384,6 +3424,13 @@ run-parallel@^1.1.9: dependencies: 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: version "1.1.3" 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" 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" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==