Adding more to registration
This commit is contained in:
parent
247b61f81b
commit
585029e082
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@ -0,0 +1 @@
|
|||||||
|
npm test
|
||||||
45
.prettierignore
Normal file
45
.prettierignore
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Production builds
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Package manager files
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Public assets
|
||||||
|
public/
|
||||||
|
|
||||||
|
# MSW
|
||||||
|
mockServiceWorker.js
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
15
.prettierrc
Normal file
15
.prettierrc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
}
|
||||||
@ -19,9 +19,9 @@ A modern backoffice admin panel built with [Next.js](https://nextjs.org/) and [R
|
|||||||
|
|
||||||
## 📦 Getting Started
|
## 📦 Getting Started
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.luckyigaming.com/Mitchell/payment-backoffice.git
|
git clone https://git.luckyigaming.com/Mitchell/payment-backoffice.git
|
||||||
cd backoffice
|
cd backoffice
|
||||||
|
|
||||||
yarn run dev
|
yarn run dev
|
||||||
|
```
|
||||||
|
|||||||
41
app/AuthBootstrap.tsx
Normal file
41
app/AuthBootstrap.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// app/AuthBootstrap.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import { AppDispatch } from "@/app/redux/types";
|
||||||
|
import { validateAuth } from "./redux/auth/authSlice";
|
||||||
|
|
||||||
|
export function AuthBootstrap() {
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const startedRef = useRef(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Guard against React StrictMode double-invoke in dev
|
||||||
|
if (startedRef.current) return;
|
||||||
|
startedRef.current = true;
|
||||||
|
|
||||||
|
// Initial validate on mount
|
||||||
|
dispatch(validateAuth());
|
||||||
|
|
||||||
|
// Refresh on window focus (nice UX)
|
||||||
|
const onFocus = () => dispatch(validateAuth());
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
|
||||||
|
// Optional: periodic validation every 5 min
|
||||||
|
intervalRef.current = setInterval(
|
||||||
|
() => {
|
||||||
|
dispatch(validateAuth());
|
||||||
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("focus", onFocus);
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
20
app/ReduxProvider.tsx
Normal file
20
app/ReduxProvider.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
import { PersistGate } from "redux-persist/integration/react";
|
||||||
|
import { store, persistor } from "./redux/store";
|
||||||
|
|
||||||
|
export default function ReduxProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<PersistGate loading={null} persistor={persistor}>
|
||||||
|
{children}
|
||||||
|
</PersistGate>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
app/api/auth/change-password/route.ts
Normal file
132
app/api/auth/change-password/route.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { decodeJwt } from "jose";
|
||||||
|
|
||||||
|
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
||||||
|
const COOKIE_NAME = "auth_token";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { email, currentPassword, newPassword } = await request.json();
|
||||||
|
|
||||||
|
// Get the auth token from cookies first
|
||||||
|
const { cookies } = await import("next/headers");
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const token = (await cookieStore).get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "No authentication token found" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check MustChangePassword flag from current token
|
||||||
|
let mustChangePassword = false;
|
||||||
|
try {
|
||||||
|
const payload = decodeJwt(token);
|
||||||
|
mustChangePassword = payload.MustChangePassword || false;
|
||||||
|
console.log(
|
||||||
|
"🔍 Current JWT MustChangePassword flag:",
|
||||||
|
mustChangePassword
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to decode current JWT:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip currentPassword validation if MustChangePassword is true
|
||||||
|
if (!email || !newPassword || (!mustChangePassword && !currentPassword)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Email, current password, and new password are required",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔁 Call backend API with the correct payload
|
||||||
|
const requestBody: {
|
||||||
|
email: string;
|
||||||
|
new_password: string;
|
||||||
|
current_password?: string;
|
||||||
|
} = {
|
||||||
|
email,
|
||||||
|
new_password: newPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include current_password if MustChangePassword is false
|
||||||
|
if (!mustChangePassword && currentPassword) {
|
||||||
|
requestBody.current_password = currentPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/change-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.log("[DEBUG] [CHANGE-PASSWORD] Error response:", {
|
||||||
|
status: resp.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: data?.message || "Password change failed" },
|
||||||
|
{ status: resp.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Handle new token from backend
|
||||||
|
const newToken = data?.token;
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: data?.message || "Password changed successfully",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newToken) {
|
||||||
|
try {
|
||||||
|
const payload = decodeJwt(newToken);
|
||||||
|
console.log("🔍 New JWT payload:", payload);
|
||||||
|
console.log(
|
||||||
|
"🔍 must_change_password flag:",
|
||||||
|
payload.must_change_password
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed to decode new JWT:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive maxAge from JWT exp if available; fallback to 12h
|
||||||
|
let maxAge = 60 * 60 * 12;
|
||||||
|
try {
|
||||||
|
const payload = decodeJwt(newToken);
|
||||||
|
if (payload?.exp) {
|
||||||
|
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
|
||||||
|
if (secondsLeft > 0) maxAge = secondsLeft;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
response.cookies.set({
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: newToken,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Change password proxy error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,64 +1,92 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { SignJWT } from "jose";
|
import { decodeJwt } from "jose";
|
||||||
|
|
||||||
// Secret key for JWT signing (in production, use environment variable)
|
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
||||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
|
const COOKIE_NAME = "auth_token";
|
||||||
|
|
||||||
const TOKEN_EXPIRY = 60 * 60 * 12; // 12 hours (in seconds)
|
|
||||||
|
|
||||||
// This is your POST handler for the login endpoint
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { email, password } = await request.json();
|
const { email, password } = await request.json();
|
||||||
|
|
||||||
// --- Replace with your ACTUAL authentication logic ---
|
// Call backend login
|
||||||
// In a real application, you would:
|
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/login`, {
|
||||||
// 1. Query your database for the user by email.
|
method: "POST",
|
||||||
// 2. Hash the provided password and compare it to the stored hashed password.
|
headers: { "Content-Type": "application/json" },
|
||||||
// 3. If credentials match, generate a secure JWT (JSON Web Token) or session ID.
|
body: JSON.stringify({ email, password }),
|
||||||
// 4. Store the token/session ID securely (e.g., in a database or Redis).
|
});
|
||||||
|
|
||||||
// Mock authentication for demonstration purposes:
|
|
||||||
if (email === "admin@example.com" && password === "password123") {
|
|
||||||
// Create JWT token with expiration
|
|
||||||
const token = await new SignJWT({
|
|
||||||
email,
|
|
||||||
role: "admin",
|
|
||||||
iat: Math.floor(Date.now() / 1000), // issued at
|
|
||||||
})
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime(Math.floor(Date.now() / 1000) + TOKEN_EXPIRY)
|
|
||||||
.sign(JWT_SECRET);
|
|
||||||
|
|
||||||
// Set the authentication token as an HTTP-only cookie
|
|
||||||
// HTTP-only cookies are crucial for security as they cannot be accessed by client-side JavaScript,
|
|
||||||
// which mitigates XSS attacks.
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set("auth_token", token, {
|
|
||||||
httpOnly: true, // IMPORTANT: Makes the cookie inaccessible to client-side scripts
|
|
||||||
secure: process.env.NODE_ENV === "production", // Use secure in production (HTTPS)
|
|
||||||
maxAge: TOKEN_EXPIRY, // 24 hours
|
|
||||||
path: "/", // Available across the entire site
|
|
||||||
sameSite: "lax", // Protects against CSRF
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const errJson = await safeJson(resp);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: true, message: "Login successful" },
|
{ success: false, message: errJson?.message || "Login failed" },
|
||||||
{ status: 200 }
|
{ status: resp.status }
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, message: "Invalid credentials" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
const token: string | undefined = data?.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "No token returned from backend" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode JWT token to extract MustChangePassword
|
||||||
|
let mustChangePassword = false;
|
||||||
|
let maxAge = 60 * 60 * 12; // fallback to 12h
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = decodeJwt(token);
|
||||||
|
|
||||||
|
// Extract exp if present
|
||||||
|
if (payload?.exp) {
|
||||||
|
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
|
||||||
|
if (secondsLeft > 0) maxAge = secondsLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract MustChangePassword flag if it exists
|
||||||
|
if (typeof payload?.MustChangePassword === "boolean") {
|
||||||
|
mustChangePassword = payload.MustChangePassword;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to decode JWT:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the cookie
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
user: data.user,
|
||||||
|
success: true,
|
||||||
|
message: "Login successful",
|
||||||
|
must_change_password: mustChangePassword,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login API error:", error);
|
console.error("Login proxy error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: "Internal server error" },
|
{ success: false, message: "Internal server error" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function safeJson(resp: Response) {
|
||||||
|
try {
|
||||||
|
return await resp.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,24 +1,28 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
// This is your DELETE handler for the logout endpoint
|
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:3000";
|
||||||
export async function DELETE() {
|
const COOKIE_NAME = "auth_token";
|
||||||
try {
|
|
||||||
// Clear the authentication cookie.
|
|
||||||
// This MUST match the name of the cookie set during login.
|
|
||||||
// In your login handler, the cookie is named "auth_token".
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.delete("auth_token");
|
|
||||||
|
|
||||||
return NextResponse.json(
|
export async function DELETE() {
|
||||||
{ success: true, message: "Logged out successfully" },
|
const cookieStore = await cookies();
|
||||||
{ status: 200 },
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
);
|
|
||||||
} catch (error) {
|
if (token) {
|
||||||
console.error("Logout API error:", error);
|
try {
|
||||||
return NextResponse.json(
|
await fetch(`${BE_BASE_URL}/logout`, {
|
||||||
{ success: false, message: "Internal server error during logout" },
|
method: "POST",
|
||||||
{ status: 500 },
|
headers: {
|
||||||
);
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`, // satisfy requireJwt
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token }), // satisfy body check
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("BE /logout failed:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cookieStore.delete(COOKIE_NAME);
|
||||||
|
return NextResponse.json({ success: true, message: "Logged out" });
|
||||||
}
|
}
|
||||||
|
|||||||
138
app/api/auth/register/route.ts
Normal file
138
app/api/auth/register/route.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { formatPhoneDisplay } from "@/app/features/UserRoles/utils";
|
||||||
|
|
||||||
|
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
||||||
|
const COOKIE_NAME = "auth_token";
|
||||||
|
|
||||||
|
// Interface matching the backend RegisterRequest
|
||||||
|
interface RegisterRequest {
|
||||||
|
creator: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
groups: string[];
|
||||||
|
job_title: string;
|
||||||
|
last_name: string;
|
||||||
|
merchants: string[];
|
||||||
|
phone: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend form interface
|
||||||
|
interface FrontendRegisterForm {
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
username: string;
|
||||||
|
phone?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
groups?: string[];
|
||||||
|
merchants?: string[];
|
||||||
|
creator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body: FrontendRegisterForm = await request.json();
|
||||||
|
|
||||||
|
// Get the auth token from cookies
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "No authentication token found",
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const requiredFields = ["email", "firstName", "lastName", "username"];
|
||||||
|
const missingFields = requiredFields.filter(
|
||||||
|
field => !body[field as keyof FrontendRegisterForm]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: `Missing required fields: ${missingFields.join(", ")}`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map frontend payload to backend RegisterRequest format
|
||||||
|
const registerPayload: RegisterRequest = {
|
||||||
|
creator: body.creator || randomUUID(), // Generate UUID if not provided
|
||||||
|
email: body.email,
|
||||||
|
first_name: body.firstName,
|
||||||
|
groups: body.groups || ["Reader"], // Default to empty array if not provided
|
||||||
|
job_title: body.jobTitle || "Reader",
|
||||||
|
last_name: body.lastName,
|
||||||
|
merchants: body.merchants || ["Win Bot"], // Default to empty array if not provided
|
||||||
|
phone: body.phone ? formatPhoneDisplay(body.phone, body.countryCode) : "",
|
||||||
|
username: body.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call backend registration endpoint
|
||||||
|
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(registerPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[DEBUG] [REGISTER-PAYLOAD]: ", registerPayload);
|
||||||
|
|
||||||
|
// Handle backend response
|
||||||
|
if (!resp.ok) {
|
||||||
|
const errorData = await safeJson(resp);
|
||||||
|
console.log("[DEBUG] [REGISTER-ERROR]: ", errorData);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: errorData?.message || "Registration failed",
|
||||||
|
},
|
||||||
|
{ status: resp.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
console.log("[DEBUG] [REGISTER]: ", data);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Registration successful",
|
||||||
|
user: data.user || null,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Registration proxy error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Internal server error during registration",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to safely parse JSON responses
|
||||||
|
async function safeJson(resp: Response) {
|
||||||
|
try {
|
||||||
|
return await resp.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/auth/validate/route.ts
Normal file
35
app/api/auth/validate/route.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
||||||
|
const COOKIE_NAME = "auth_token";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ valid: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
return NextResponse.json(data, { status: resp.status });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, message: "Validation failed", error: message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,46 +1,45 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
// app/api/users/[id]/route.ts
|
||||||
import { users, type User } from "../../mockData"; // adjust relative path
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
type UpdateUserBody = Partial<{
|
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
||||||
firstName: string;
|
const COOKIE_NAME = "auth_token";
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
role: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export async function PUT(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
console.log("[PATCH /users] - params", params);
|
||||||
} catch {
|
const { id } = params;
|
||||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
const body = await request.json();
|
||||||
|
// Get the auth token from cookies
|
||||||
|
const { cookies } = await import("next/headers");
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const token = (await cookieStore).get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Missing Authorization header" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BE_BASE_URL}/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Proxy PATCH /users error:", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error", error: err.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { firstName, lastName, email, phone, role } = body as UpdateUserBody;
|
|
||||||
|
|
||||||
const userIndex = users.findIndex((u: User) => u.id === id);
|
|
||||||
if (userIndex === -1) {
|
|
||||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = users[userIndex];
|
|
||||||
|
|
||||||
const updatedUser: User = {
|
|
||||||
...existingUser,
|
|
||||||
firstName: firstName ?? existingUser.firstName,
|
|
||||||
lastName: lastName ?? existingUser.lastName,
|
|
||||||
email: email ?? existingUser.email,
|
|
||||||
phone: phone ?? existingUser.phone,
|
|
||||||
authorities: role ? [role] : existingUser.authorities ?? [],
|
|
||||||
};
|
|
||||||
|
|
||||||
users[userIndex] = updatedUser;
|
|
||||||
|
|
||||||
return NextResponse.json(updatedUser, { status: 200 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
|
||||||
@ -7,8 +7,8 @@ export async function GET(request: NextRequest) {
|
|||||||
let filteredApproveRows = [...approveRows];
|
let filteredApproveRows = [...approveRows];
|
||||||
|
|
||||||
if (merchantId) {
|
if (merchantId) {
|
||||||
filteredApproveRows = filteredApproveRows.filter((tx) =>
|
filteredApproveRows = filteredApproveRows.filter(tx =>
|
||||||
tx.merchantId.toString().includes(merchantId),
|
tx.merchantId.toString().includes(merchantId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,23 +16,22 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (actionType) {
|
if (actionType) {
|
||||||
filteredRows = filteredRows.filter(
|
filteredRows = filteredRows.filter(
|
||||||
(tx) =>
|
tx => tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase()
|
||||||
tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (affectedUserId) {
|
if (affectedUserId) {
|
||||||
filteredRows = filteredRows.filter(
|
filteredRows = filteredRows.filter(
|
||||||
(tx) => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase(),
|
tx => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminId) {
|
if (adminId) {
|
||||||
filteredRows = filteredRows.filter((tx) => tx.adminId === adminId);
|
filteredRows = filteredRows.filter(tx => tx.adminId === adminId);
|
||||||
}
|
}
|
||||||
if (adminUsername) {
|
if (adminUsername) {
|
||||||
filteredRows = filteredRows.filter(
|
filteredRows = filteredRows.filter(
|
||||||
(tx) => tx.adminUsername === adminUsername,
|
tx => tx.adminUsername === adminUsername
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,11 +45,11 @@ export async function GET(request: NextRequest) {
|
|||||||
{
|
{
|
||||||
error: "Invalid date range",
|
error: "Invalid date range",
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredRows = filteredRows.filter((tx) => {
|
filteredRows = filteredRows.filter(tx => {
|
||||||
const txDate = new Date(tx.timeStampOfTheAction);
|
const txDate = new Date(tx.timeStampOfTheAction);
|
||||||
|
|
||||||
// Validate if the timestamp is a valid date
|
// Validate if the timestamp is a valid date
|
||||||
|
|||||||
@ -232,8 +232,7 @@ export const allTransactionsExtraColumns: GridColDef[] = [
|
|||||||
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
|
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const extraColumns = ["currency", "errorInfo", "fraudScore"]
|
export const extraColumns = ["currency", "errorInfo", "fraudScore"];
|
||||||
|
|
||||||
|
|
||||||
export const allTransactionsSearchLabels = [
|
export const allTransactionsSearchLabels = [
|
||||||
{ label: "User", field: "userId", type: "text" },
|
{ label: "User", field: "userId", type: "text" },
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { allTransactionDummyData, allTransactionsColumns, allTransactionsSearchLabels, extraColumns } from "./mockData";
|
import {
|
||||||
|
allTransactionDummyData,
|
||||||
|
allTransactionsColumns,
|
||||||
|
allTransactionsSearchLabels,
|
||||||
|
extraColumns,
|
||||||
|
} from "./mockData";
|
||||||
|
|
||||||
// import { formatToDateTimeString } from "@/app/utils/formatDate";
|
// import { formatToDateTimeString } from "@/app/utils/formatDate";
|
||||||
|
|
||||||
@ -20,29 +25,29 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.userId.toString() === userId,
|
tx => tx.userId.toString() === userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.status.toLowerCase() === status.toLowerCase(),
|
tx => tx.status.toLowerCase() === status.toLowerCase()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (depositMethod) {
|
if (depositMethod) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase(),
|
tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (merchandId) {
|
if (merchandId) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.merchandId.toString() === merchandId,
|
tx => tx.merchandId.toString() === merchandId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (transactionId) {
|
if (transactionId) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.transactionId.toString() === transactionId,
|
tx => tx.transactionId.toString() === transactionId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,11 +60,11 @@ export async function GET(request: NextRequest) {
|
|||||||
{
|
{
|
||||||
error: "Invalid date range",
|
error: "Invalid date range",
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredTransactions = filteredTransactions.filter((tx) => {
|
filteredTransactions = filteredTransactions.filter(tx => {
|
||||||
const txDate = new Date(tx.dateTime);
|
const txDate = new Date(tx.dateTime);
|
||||||
|
|
||||||
if (isNaN(txDate.getTime())) {
|
if (isNaN(txDate.getTime())) {
|
||||||
|
|||||||
@ -24,29 +24,29 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.userId.toString() === userId,
|
tx => tx.userId.toString() === userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.status.toLowerCase() === status.toLowerCase(),
|
tx => tx.status.toLowerCase() === status.toLowerCase()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (depositMethod) {
|
if (depositMethod) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase(),
|
tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (merchandId) {
|
if (merchandId) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.merchandId.toString() === merchandId,
|
tx => tx.merchandId.toString() === merchandId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (transactionId) {
|
if (transactionId) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.transactionId.toString() === transactionId,
|
tx => tx.transactionId.toString() === transactionId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,11 +59,11 @@ export async function GET(request: NextRequest) {
|
|||||||
{
|
{
|
||||||
error: "Invalid date range",
|
error: "Invalid date range",
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredTransactions = filteredTransactions.filter((tx) => {
|
filteredTransactions = filteredTransactions.filter(tx => {
|
||||||
const txDate = new Date(tx.dateTime);
|
const txDate = new Date(tx.dateTime);
|
||||||
|
|
||||||
if (isNaN(txDate.getTime())) {
|
if (isNaN(txDate.getTime())) {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Error",
|
status: "Error",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -26,7 +26,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Error",
|
status: "Error",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -45,7 +45,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Completed",
|
status: "Completed",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -62,7 +62,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049136973,
|
transactionId: 1049136973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Completed",
|
status: "Completed",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -79,7 +79,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Error",
|
status: "Error",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -98,7 +98,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Error",
|
status: "Error",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -117,7 +117,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Error",
|
status: "Error",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -136,7 +136,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Card",
|
withdrawalMethod: "Card",
|
||||||
status: "Pending",
|
status: "Pending",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -153,7 +153,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Inprogress",
|
status: "Inprogress",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -170,7 +170,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Error",
|
status: "Error",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -189,7 +189,7 @@ export const withdrawalTransactionDummyData = [
|
|||||||
transactionId: 1049131973,
|
transactionId: 1049131973,
|
||||||
withdrawalMethod: "Bank Transfer",
|
withdrawalMethod: "Bank Transfer",
|
||||||
status: "Error",
|
status: "Error",
|
||||||
options: [
|
options: [
|
||||||
{ value: "Pending", label: "Pending" },
|
{ value: "Pending", label: "Pending" },
|
||||||
{ value: "Completed", label: "Completed" },
|
{ value: "Completed", label: "Completed" },
|
||||||
{ value: "Inprogress", label: "Inprogress" },
|
{ value: "Inprogress", label: "Inprogress" },
|
||||||
@ -209,7 +209,7 @@ export const withdrawalTransactionsColumns: GridColDef[] = [
|
|||||||
{ field: "transactionId", headerName: "Transaction ID", width: 130 },
|
{ field: "transactionId", headerName: "Transaction ID", width: 130 },
|
||||||
{ field: "withdrawalMethod", headerName: "Withdrawal Method", width: 130 },
|
{ field: "withdrawalMethod", headerName: "Withdrawal Method", width: 130 },
|
||||||
{ field: "status", headerName: "Status", width: 130 },
|
{ field: "status", headerName: "Status", width: 130 },
|
||||||
{ field: "actions", headerName: "Actions", width: 150 },
|
{ field: "actions", headerName: "Actions", width: 150 },
|
||||||
{ field: "amount", headerName: "Amount", width: 130 },
|
{ field: "amount", headerName: "Amount", width: 130 },
|
||||||
{ field: "dateTime", headerName: "Date / Time", width: 130 },
|
{ field: "dateTime", headerName: "Date / Time", width: 130 },
|
||||||
{ field: "errorInfo", headerName: "Error Info", width: 130 },
|
{ field: "errorInfo", headerName: "Error Info", width: 130 },
|
||||||
|
|||||||
@ -19,13 +19,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.userId.toString() === userId,
|
tx => tx.userId.toString() === userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.status.toLowerCase() === status.toLowerCase(),
|
tx => tx.status.toLowerCase() === status.toLowerCase()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,11 +38,11 @@ export async function GET(request: NextRequest) {
|
|||||||
{
|
{
|
||||||
error: "Invalid date range",
|
error: "Invalid date range",
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredTransactions = filteredTransactions.filter((tx) => {
|
filteredTransactions = filteredTransactions.filter(tx => {
|
||||||
const txDate = new Date(tx.dateTime);
|
const txDate = new Date(tx.dateTime);
|
||||||
|
|
||||||
if (isNaN(txDate.getTime())) {
|
if (isNaN(txDate.getTime())) {
|
||||||
@ -55,8 +55,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (withdrawalMethod) {
|
if (withdrawalMethod) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) =>
|
tx => tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase()
|
||||||
tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,65 +1,83 @@
|
|||||||
interface Transaction {
|
interface Transaction {
|
||||||
userId: number | string;
|
userId: number | string;
|
||||||
date: string;
|
date: string;
|
||||||
method: string;
|
method: string;
|
||||||
amount: number | string;
|
amount: number | string;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deposits: Transaction[] = [
|
export const deposits: Transaction[] = [
|
||||||
{ userId: 17, date: "2025-08-01 10:10", method: "CC", amount: 120, status: "approved" },
|
{
|
||||||
{
|
userId: 17,
|
||||||
userId: 17,
|
date: "2025-08-01 10:10",
|
||||||
date: "2025-07-28 14:35",
|
method: "CC",
|
||||||
method: "Bank Transfer",
|
amount: 120,
|
||||||
amount: 250,
|
status: "approved",
|
||||||
status: "approved",
|
},
|
||||||
},
|
{
|
||||||
{
|
userId: 17,
|
||||||
userId: 17,
|
date: "2025-07-28 14:35",
|
||||||
date: "2025-07-20 09:05",
|
method: "Bank Transfer",
|
||||||
method: "PayPal",
|
amount: 250,
|
||||||
amount: 75,
|
status: "approved",
|
||||||
status: "pending",
|
},
|
||||||
},
|
{
|
||||||
{ userId: 17,
|
userId: 17,
|
||||||
date: "2025-07-11 17:12", method: "CC", amount: 300, status: "rejected" },
|
date: "2025-07-20 09:05",
|
||||||
{ userId: 17,
|
method: "PayPal",
|
||||||
date: "2025-07-01 12:42", method: "CC", amount: 180, status: "approved" },
|
amount: 75,
|
||||||
];
|
status: "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 17,
|
||||||
|
date: "2025-07-11 17:12",
|
||||||
|
method: "CC",
|
||||||
|
amount: 300,
|
||||||
|
status: "rejected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 17,
|
||||||
|
date: "2025-07-01 12:42",
|
||||||
|
method: "CC",
|
||||||
|
amount: 180,
|
||||||
|
status: "approved",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const withdrawals: Transaction[] = [
|
export const withdrawals: Transaction[] = [
|
||||||
{
|
{
|
||||||
userId: 17,
|
userId: 17,
|
||||||
date: "2025-08-02 11:20",
|
date: "2025-08-02 11:20",
|
||||||
method: "Crypto",
|
method: "Crypto",
|
||||||
amount: 95,
|
amount: 95,
|
||||||
status: "processing",
|
status: "processing",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 17,
|
userId: 17,
|
||||||
date: "2025-07-29 16:45",
|
date: "2025-07-29 16:45",
|
||||||
method: "Bank Transfer",
|
method: "Bank Transfer",
|
||||||
amount: 220,
|
amount: 220,
|
||||||
status: "approved",
|
status: "approved",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 17,
|
userId: 17,
|
||||||
date: "2025-07-21 15:10",
|
date: "2025-07-21 15:10",
|
||||||
method: "eWallet",
|
method: "eWallet",
|
||||||
amount: 60,
|
amount: 60,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
{ userId: 17,
|
{
|
||||||
date: "2025-07-12 13:33",
|
userId: 17,
|
||||||
method: "Crypto",
|
date: "2025-07-12 13:33",
|
||||||
amount: 120,
|
method: "Crypto",
|
||||||
status: "approved",
|
amount: 120,
|
||||||
},
|
status: "approved",
|
||||||
{ userId: 17,
|
},
|
||||||
date: "2025-07-03 08:50",
|
{
|
||||||
method: "Bank Transfer",
|
userId: 17,
|
||||||
amount: 150,
|
date: "2025-07-03 08:50",
|
||||||
status: "rejected",
|
method: "Bank Transfer",
|
||||||
},
|
amount: 150,
|
||||||
];
|
status: "rejected",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@ -7,12 +7,15 @@ export async function GET(request: NextRequest) {
|
|||||||
let filteredDeposits = [...deposits];
|
let filteredDeposits = [...deposits];
|
||||||
let filteredwithdrawals = [...withdrawals];
|
let filteredwithdrawals = [...withdrawals];
|
||||||
|
|
||||||
if( userId ){
|
if (userId) {
|
||||||
filteredDeposits = filteredDeposits.filter((item) => item.userId.toString() === userId.toString())
|
filteredDeposits = filteredDeposits.filter(
|
||||||
filteredwithdrawals = filteredwithdrawals.filter((item) => item.userId.toString() === userId.toString())
|
item => item.userId.toString() === userId.toString()
|
||||||
|
);
|
||||||
|
filteredwithdrawals = filteredwithdrawals.filter(
|
||||||
|
item => item.userId.toString() === userId.toString()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
deposits: filteredDeposits,
|
deposits: filteredDeposits,
|
||||||
withdrawals: filteredwithdrawals,
|
withdrawals: filteredwithdrawals,
|
||||||
|
|||||||
0
app/api/settings/route.ts
Normal file
0
app/api/settings/route.ts
Normal file
@ -17,7 +17,7 @@
|
|||||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
max-width: 90vw;
|
max-width: 60vw;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 2rem 1.5rem 1.5rem 1.5rem;
|
padding: 2rem 1.5rem 1.5rem 1.5rem;
|
||||||
@ -49,5 +49,5 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #222;
|
color: #222;
|
||||||
width: 500px;
|
min-width: 450px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ const Modal: React.FC<ModalProps> = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`modal${className ? " " + className : ""}`}
|
className={`modal${className ? " " + className : ""}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
data-testid="modal-content"
|
data-testid="modal-content"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,20 +1,20 @@
|
|||||||
.page-link__container {
|
.page-link__container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 1px;
|
padding: 12px 1px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.2s ease-in-out;
|
transition: background 0.2s ease-in-out;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: var(--hover-color);
|
background-color: var(--hover-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.page-link__text {
|
.page-link__text {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,43 +1,43 @@
|
|||||||
.search-filters {
|
.search-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: #e0e0e0;
|
background-color: #e0e0e0;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-label {
|
.chip-label {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-label.bold {
|
.chip-label.bold {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-delete {
|
.chip-delete {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-all {
|
.clear-all {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: black;
|
color: black;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ const SearchFilters = ({ filters }: SearchFiltersProps) => {
|
|||||||
|
|
||||||
const allFilters = [
|
const allFilters = [
|
||||||
...Object.entries(filters).filter(
|
...Object.entries(filters).filter(
|
||||||
([key]) => key !== "dateTime_start" && key !== "dateTime_end",
|
([key]) => key !== "dateTime_start" && key !== "dateTime_end"
|
||||||
),
|
),
|
||||||
...(hasDateRange
|
...(hasDateRange
|
||||||
? [
|
? [
|
||||||
@ -65,7 +65,7 @@ const SearchFilters = ({ filters }: SearchFiltersProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="search-filters">
|
<div className="search-filters">
|
||||||
{allFilters.map(([key, value]) =>
|
{allFilters.map(([key, value]) =>
|
||||||
value ? renderChip(filterLabels[key] ?? key, value, key) : null,
|
value ? renderChip(filterLabels[key] ?? key, value, key) : null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.values(filters).some(Boolean) && (
|
{Object.values(filters).some(Boolean) && (
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
import Users from "@/app/features/Pages/Admin/Users/users";
|
import Users from "@/app/features/Pages/Admin/Users/users";
|
||||||
|
|
||||||
export default async function BackOfficeUsersPage() {
|
export default async function BackOfficeUsersPage() {
|
||||||
const baseUrl =
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
|
||||||
process.env.NEXT_PUBLIC_BASE_URL
|
? `${process.env.NEXT_PUBLIC_BASE_URL}`
|
||||||
? `${process.env.NEXT_PUBLIC_BASE_URL}`
|
: "http://localhost:4000";
|
||||||
: "http://localhost:3000";
|
// const res = await fetch(`${baseUrl}/api/dashboard/admin/users`, {
|
||||||
const res = await fetch(`${baseUrl}/api/dashboard/admin/users`, {
|
// cache: "no-store", // 👈 disables caching for SSR freshness
|
||||||
cache: "no-store", // 👈 disables caching for SSR freshness
|
// });
|
||||||
});
|
// const users = await res.json();
|
||||||
const users = await res.json();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Users users={users} />
|
<Users users={[]} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import {
|
import { ApproveTable } from "@/app/features/Pages/Approve/Approve";
|
||||||
ApproveTable,
|
|
||||||
} from "@/app/features/Pages/Approve/Approve";
|
|
||||||
import { getApproves } from "@/app/services/approve";
|
import { getApproves } from "@/app/services/approve";
|
||||||
|
|
||||||
|
|
||||||
export default async function Approve({
|
export default async function Approve({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@ -21,8 +18,5 @@ export default async function Approve({
|
|||||||
const query = new URLSearchParams(safeParams).toString();
|
const query = new URLSearchParams(safeParams).toString();
|
||||||
const data = await getApproves({ query });
|
const data = await getApproves({ query });
|
||||||
|
|
||||||
return (
|
return <ApproveTable data={data} />;
|
||||||
<ApproveTable data={data} />
|
}
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// This ensures this component is rendered only on the client side
|
// This ensures this component is rendered only on the client side
|
||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
|
|
||||||
export default function InvestigatePage() {
|
export default function InvestigatePage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
21
app/dashboard/settings/page.tsx
Normal file
21
app/dashboard/settings/page.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import SettingsPageClient from "@/app/features/Pages/Settings/SettingsPageClient";
|
||||||
|
|
||||||
|
// We can enable this if we want to fetch the user from the database by the id but it's not necessary since we already have the user in the redux store
|
||||||
|
// async function getUser() {
|
||||||
|
// const token = (await cookies()).get("auth_token")?.value;
|
||||||
|
// const payload = token ? await validateToken(token) : null;
|
||||||
|
// const userId = payload?.id; // requires JWT to include id
|
||||||
|
// if (!userId) throw new Error("No user id in token");
|
||||||
|
// const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/dashboard/admin/users/${userId}`, {
|
||||||
|
// headers: { Authorization: `Bearer ${token}` },
|
||||||
|
// cache: "no-store",
|
||||||
|
// });
|
||||||
|
// if (!res.ok) throw new Error("Failed to fetch user");
|
||||||
|
// return res.json();
|
||||||
|
// }
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
// const user = await getUser();
|
||||||
|
|
||||||
|
return <SettingsPageClient />;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
.account-iq {
|
.account-iq {
|
||||||
.account-iq__icon {
|
.account-iq__icon {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #4ecdc4;
|
color: #4ecdc4;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
});
|
});
|
||||||
router.push(`?${updatedParams.toString()}`);
|
router.push(`?${updatedParams.toString()}`);
|
||||||
}, 500),
|
}, 500),
|
||||||
[router],
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFieldChange = (field: string, value: string) => {
|
const handleFieldChange = (field: string, value: string) => {
|
||||||
@ -132,9 +132,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
value={formValues[field] || ""}
|
value={formValues[field] || ""}
|
||||||
onChange={(e) =>
|
onChange={e => handleFieldChange(field, e.target.value)}
|
||||||
handleFieldChange(field, e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -142,7 +140,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small">
|
||||||
<Select
|
<Select
|
||||||
value={formValues[field] || ""}
|
value={formValues[field] || ""}
|
||||||
onChange={(e) =>
|
onChange={e =>
|
||||||
handleFieldChange(field, e.target.value)
|
handleFieldChange(field, e.target.value)
|
||||||
}
|
}
|
||||||
displayEmpty
|
displayEmpty
|
||||||
@ -150,7 +148,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
<MenuItem value="">
|
<MenuItem value="">
|
||||||
<em>{label}</em>
|
<em>{label}</em>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{options?.map((option) => (
|
{options?.map(option => (
|
||||||
<MenuItem value={option} key={option}>
|
<MenuItem value={option} key={option}>
|
||||||
{option}
|
{option}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -168,14 +166,14 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
? new Date(formValues[`${field}_start`])
|
? new Date(formValues[`${field}_start`])
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(newValue) => {
|
onChange={newValue => {
|
||||||
if (!newValue)
|
if (!newValue)
|
||||||
return handleFieldChange(`${field}_start`, "");
|
return handleFieldChange(`${field}_start`, "");
|
||||||
const start = new Date(newValue);
|
const start = new Date(newValue);
|
||||||
start.setHours(0, 0, 0, 0); // force start of day
|
start.setHours(0, 0, 0, 0); // force start of day
|
||||||
handleFieldChange(
|
handleFieldChange(
|
||||||
`${field}_start`,
|
`${field}_start`,
|
||||||
start.toISOString(),
|
start.toISOString()
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@ -189,14 +187,14 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
? new Date(formValues[`${field}_end`])
|
? new Date(formValues[`${field}_end`])
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(newValue) => {
|
onChange={newValue => {
|
||||||
if (!newValue)
|
if (!newValue)
|
||||||
return handleFieldChange(`${field}_end`, "");
|
return handleFieldChange(`${field}_end`, "");
|
||||||
const end = new Date(newValue);
|
const end = new Date(newValue);
|
||||||
end.setHours(23, 59, 59, 999); // force end of day
|
end.setHours(23, 59, 59, 999); // force end of day
|
||||||
handleFieldChange(
|
handleFieldChange(
|
||||||
`${field}_end`,
|
`${field}_end`,
|
||||||
end.toISOString(),
|
end.toISOString()
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
|
|||||||
208
app/features/Auth/ChangePassword/ChangePassword.scss
Normal file
208
app/features/Auth/ChangePassword/ChangePassword.scss
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
/* app/styles/LoginModal.scss (BEM Methodology) */
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
// Variables for consistent styling
|
||||||
|
$primary-color: #2563eb; // Blue-600 equivalent
|
||||||
|
$primary-hover-color: #1d4ed8; // Blue-700 equivalent
|
||||||
|
$success-color: #16a34a; // Green-600 equivalent
|
||||||
|
$error-color: #dc2626; // Red-600 equivalent
|
||||||
|
$text-color-dark: #1f2937; // Gray-800 equivalent
|
||||||
|
$text-color-medium: #4b5563; // Gray-700 equivalent
|
||||||
|
$text-color-light: #6b7280; // Gray-600 equivalent
|
||||||
|
$border-color: #d1d5db; // Gray-300 equivalent
|
||||||
|
$bg-color-light: #f3f4f6; // Gray-100 equivalent
|
||||||
|
$bg-color-white: #ffffff;
|
||||||
|
|
||||||
|
/* --- Login Modal Block (.login-modal) --- */
|
||||||
|
.login-modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(17, 24, 39, 0.75); // Gray-900 75% opacity
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 1rem; // p-4
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal__content {
|
||||||
|
background-color: $bg-color-white;
|
||||||
|
border-radius: 0.75rem; // rounded-xl
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04); // shadow-2xl
|
||||||
|
padding: 2rem; // p-8
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem; // max-w-md
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.3s ease-in-out; // transition-all duration-300
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal__title {
|
||||||
|
font-size: 1.875rem; // text-3xl
|
||||||
|
font-weight: 700; // font-bold
|
||||||
|
color: $text-color-dark;
|
||||||
|
margin-bottom: 1.5rem; // mb-6
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Login Form Block (.login-form) --- */
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem; // space-y-6
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__group {
|
||||||
|
// No specific styles needed here, just a container for label/input
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem; // text-sm
|
||||||
|
font-weight: 500; // font-medium
|
||||||
|
color: $text-color-medium;
|
||||||
|
margin-bottom: 0.25rem; // mb-1
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem; // px-4 py-2
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 0.5rem; // rounded-lg
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
|
||||||
|
font-size: 0.875rem; // sm:text-sm
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-color: $primary-color; // focus:border-blue-500
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px $primary-color,
|
||||||
|
0 0 0 3px rgba($primary-color, 0.5); // focus:ring-blue-500
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__message {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem; // text-sm
|
||||||
|
font-weight: 500; // font-medium
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__message--success {
|
||||||
|
color: $success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__message--error {
|
||||||
|
color: $error-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__button {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem 1rem; // py-3 px-4
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.5rem; // rounded-lg
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
|
||||||
|
font-size: 1.125rem; // text-lg
|
||||||
|
font-weight: 600; // font-semibold
|
||||||
|
color: $bg-color-white;
|
||||||
|
background-color: $primary-color;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.3s ease-in-out,
|
||||||
|
box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out
|
||||||
|
&:hover {
|
||||||
|
background-color: color.adjust(
|
||||||
|
$primary-color,
|
||||||
|
$lightness: -5%
|
||||||
|
); // blue-700 equivalent
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 0.5),
|
||||||
|
0 0 0 4px rgba($primary-color, 0.5); // focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
height: 1.25rem; // h-5
|
||||||
|
width: 1.25rem; // w-5
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Page Container Block (.page-container) --- */
|
||||||
|
.page-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: $bg-color-light;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: "Inter", sans-serif; // Assuming Inter font is used
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container__content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 56rem; // max-w-4xl
|
||||||
|
background-color: $bg-color-white;
|
||||||
|
border-radius: 0.75rem; // rounded-xl
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
|
||||||
|
padding: 2rem; // p-8
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container__title {
|
||||||
|
font-size: 2.25rem; // text-4xl
|
||||||
|
font-weight: 700; // font-bold
|
||||||
|
color: $text-color-dark;
|
||||||
|
margin-bottom: 1.5rem; // mb-6
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container__message--logged-in {
|
||||||
|
font-size: 1.25rem; // text-xl
|
||||||
|
color: $success-color;
|
||||||
|
margin-bottom: 1rem; // mb-4
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container__text {
|
||||||
|
color: $text-color-medium;
|
||||||
|
margin-bottom: 1.5rem; // mb-6
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container__button--logout {
|
||||||
|
padding: 0.75rem 1.5rem; // px-6 py-3
|
||||||
|
background-color: $error-color;
|
||||||
|
color: $bg-color-white;
|
||||||
|
font-weight: 600; // font-semibold
|
||||||
|
border-radius: 0.5rem; // rounded-lg
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md
|
||||||
|
transition: background-color 0.3s ease-in-out;
|
||||||
|
}
|
||||||
157
app/features/Auth/ChangePassword/ChangePassword.tsx
Normal file
157
app/features/Auth/ChangePassword/ChangePassword.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
// components/ChangePasswordModal.tsx
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Typography, TextField, Button, Box } from "@mui/material";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (passwordData: {
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword: string;
|
||||||
|
}) => void;
|
||||||
|
isUserChange?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangePassword: React.FC<Props> = ({
|
||||||
|
open,
|
||||||
|
onSubmit,
|
||||||
|
isUserChange = false,
|
||||||
|
}) => {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [password, setPassword] = useState<string>("");
|
||||||
|
const [confirm, setConfirm] = useState<string>("");
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
|
// Validate password rules
|
||||||
|
useEffect(() => {
|
||||||
|
if (!password) {
|
||||||
|
setErrors([]);
|
||||||
|
setIsValid(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newErrors: string[] = [];
|
||||||
|
if (password.length < 8) newErrors.push("At least 8 characters");
|
||||||
|
if (!/[A-Z]/.test(password)) newErrors.push("One uppercase letter");
|
||||||
|
if (!/[a-z]/.test(password)) newErrors.push("One lowercase letter");
|
||||||
|
if (!/[0-9]/.test(password)) newErrors.push("One number");
|
||||||
|
if (!/[!@#$%^&*(),.?\":{}|<>]/.test(password))
|
||||||
|
newErrors.push("One special character");
|
||||||
|
if (password !== confirm) newErrors.push("Passwords do not match");
|
||||||
|
|
||||||
|
// If user change mode, require current password
|
||||||
|
if (isUserChange && !currentPassword)
|
||||||
|
newErrors.push("Current password required");
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
setIsValid(newErrors.length === 0);
|
||||||
|
}, [confirm, password, currentPassword, isUserChange]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!isValid) {
|
||||||
|
toast.error("Please fix the validation errors before continuing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
...(isUserChange ? { currentPassword } : {}),
|
||||||
|
newPassword: password,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
width: 400,
|
||||||
|
display: open ? "flex" : "none",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
boxShadow: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUserChange ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Please enter your current password and choose a new one below.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
You’re currently logged in with a temporary password. Please set a new
|
||||||
|
one to continue.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{isUserChange && (
|
||||||
|
<TextField
|
||||||
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
error={isUserChange && !currentPassword}
|
||||||
|
helperText={
|
||||||
|
isUserChange && !currentPassword
|
||||||
|
? "Current password is required"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
error={!isValid && password.length > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={e => setConfirm(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
error={confirm.length > 0 && password !== confirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ height: "80px" }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color={isValid ? "success.main" : "error.main"}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
visibility: password.length > 0 ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{isValid
|
||||||
|
? "✓ Password meets all requirements"
|
||||||
|
: "Password must contain:"}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!isValid && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: "error.main", fontSize: "0.8rem" }}
|
||||||
|
>
|
||||||
|
{errors.join(", ")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
sx={{ width: "100%", height: 40, fontSize: 16 }}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid}
|
||||||
|
>
|
||||||
|
Update Password
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
/* app/styles/LoginModal.scss (BEM Methodology) */
|
/* app/styles/LoginModal.scss (BEM Methodology) */
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
// Variables for consistent styling
|
// Variables for consistent styling
|
||||||
$primary-color: #2563eb; // Blue-600 equivalent
|
$primary-color: #2563eb; // Blue-600 equivalent
|
||||||
@ -30,7 +31,8 @@ $bg-color-white: #ffffff;
|
|||||||
.login-modal__content {
|
.login-modal__content {
|
||||||
background-color: $bg-color-white;
|
background-color: $bg-color-white;
|
||||||
border-radius: 0.75rem; // rounded-xl
|
border-radius: 0.75rem; // rounded-xl
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
0 10px 10px -5px rgba(0, 0, 0, 0.04); // shadow-2xl
|
0 10px 10px -5px rgba(0, 0, 0, 0.04); // shadow-2xl
|
||||||
padding: 2rem; // p-8
|
padding: 2rem; // p-8
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -79,7 +81,9 @@ $bg-color-white: #ffffff;
|
|||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-color: $primary-color; // focus:border-blue-500
|
border-color: $primary-color; // focus:border-blue-500
|
||||||
box-shadow: 0 0 0 1px $primary-color, 0 0 0 3px rgba($primary-color, 0.5); // focus:ring-blue-500
|
box-shadow:
|
||||||
|
0 0 0 1px $primary-color,
|
||||||
|
0 0 0 3px rgba($primary-color, 0.5); // focus:ring-blue-500
|
||||||
}
|
}
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@ -114,13 +118,19 @@ $bg-color-white: #ffffff;
|
|||||||
color: $bg-color-white;
|
color: $bg-color-white;
|
||||||
background-color: $primary-color;
|
background-color: $primary-color;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out
|
transition:
|
||||||
|
background-color 0.3s ease-in-out,
|
||||||
|
box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: darken($primary-color, 5%); // blue-700 equivalent
|
background-color: color.adjust(
|
||||||
|
$primary-color,
|
||||||
|
$lightness: -5%
|
||||||
|
); // blue-700 equivalent
|
||||||
}
|
}
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5),
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 0.5),
|
||||||
0 0 0 4px rgba($primary-color, 0.5); // focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
|
0 0 0 4px rgba($primary-color, 0.5); // focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
|
||||||
}
|
}
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@ -162,7 +172,8 @@ $bg-color-white: #ffffff;
|
|||||||
max-width: 56rem; // max-w-4xl
|
max-width: 56rem; // max-w-4xl
|
||||||
background-color: $bg-color-white;
|
background-color: $bg-color-white;
|
||||||
border-radius: 0.75rem; // rounded-xl
|
border-radius: 0.75rem; // rounded-xl
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
|
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
|
||||||
padding: 2rem; // p-8
|
padding: 2rem; // p-8
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import "./LoginModal.scss"; // Adjust path based on your actual structure
|
import "./LoginModal.scss"; // Adjust path based on your actual structure
|
||||||
|
import { clearAuthMessage } from "@/app/redux/auth/authSlice";
|
||||||
|
|
||||||
// Define the props interface for LoginModal
|
// Define the props interface for LoginModal
|
||||||
type LoginModalProps = {
|
type LoginModalProps = {
|
||||||
@ -19,12 +20,8 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
|||||||
|
|
||||||
// Effect to clear authentication messages when email or password inputs change
|
// Effect to clear authentication messages when email or password inputs change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// clearAuthMessage();
|
clearAuthMessage();
|
||||||
}, [
|
}, [email, password]); // Dependency array ensures effect runs when these change
|
||||||
email,
|
|
||||||
password,
|
|
||||||
// clearAuthMessage
|
|
||||||
]); // Dependency array ensures effect runs when these change
|
|
||||||
|
|
||||||
// Handler for form submission
|
// Handler for form submission
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@ -47,7 +44,7 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
|||||||
className="login-form__input"
|
className="login-form__input"
|
||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@ -64,7 +61,7 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
|||||||
className="login-form__input"
|
className="login-form__input"
|
||||||
placeholder="password123"
|
placeholder="password123"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@ -76,7 +73,6 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
|||||||
disabled={isLoading} // Disable button while loading
|
disabled={isLoading} // Disable button while loading
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
// SVG spinner for loading state
|
|
||||||
<svg
|
<svg
|
||||||
className="login-form__spinner"
|
className="login-form__spinner"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@ -67,20 +67,20 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
|||||||
|
|
||||||
const handleStatusSave = () => {
|
const handleStatusSave = () => {
|
||||||
console.log(
|
console.log(
|
||||||
`Status changed for row with ID ${selectedRowId}. New status: ${newStatus}. Reason: ${reason}`,
|
`Status changed for row with ID ${selectedRowId}. New status: ${newStatus}. Reason: ${reason}`
|
||||||
);
|
);
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
rows.map((row) =>
|
rows.map(row =>
|
||||||
row.id === selectedRowId ? { ...row, status: newStatus } : row,
|
row.id === selectedRowId ? { ...row, status: newStatus } : row
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setReason("");
|
setReason("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const getColumnsWithDropdown = (columns: TColumn[]): GridColDef[] => {
|
const getColumnsWithDropdown = (columns: TColumn[]): GridColDef[] => {
|
||||||
return columns.map((col) => {
|
return columns.map(col => {
|
||||||
if (col.field === "status") {
|
if (col.field === "status") {
|
||||||
return {
|
return {
|
||||||
...col,
|
...col,
|
||||||
@ -130,53 +130,52 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (col.field === "userId") {
|
if (col.field === "userId") {
|
||||||
return {
|
return {
|
||||||
...col,
|
...col,
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
align: "center",
|
align: "center",
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr auto",
|
gridTemplateColumns: "1fr auto",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
px: 1,
|
px: 1,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()} // keep row click from firing when clicking inside
|
onClick={e => e.stopPropagation()} // keep row click from firing when clicking inside
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
color: "text.primary",
|
color: "text.primary",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{params.value}
|
{params.value}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<IconButton
|
|
||||||
href={`/users/${params.value}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
size="small"
|
|
||||||
sx={{ p: 0.5, ml: 1 }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<OpenInNewIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
href={`/users/${params.value}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0.5, ml: 1 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<OpenInNewIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (col.field === "actions") {
|
if (col.field === "actions") {
|
||||||
return {
|
return {
|
||||||
...col,
|
...col,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
const row = tableRows.find((r) => r.id === params.id) as {
|
const row = tableRows.find(r => r.id === params.id) as {
|
||||||
id: number;
|
id: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
options?: { value: string; label: string }[];
|
options?: { value: string; label: string }[];
|
||||||
@ -188,7 +187,7 @@ if (col.field === "userId") {
|
|||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={params.value ?? row.status}
|
value={params.value ?? row.status}
|
||||||
onChange={(e) =>
|
onChange={e =>
|
||||||
handleStatusChange(params.id as number, e.target.value)
|
handleStatusChange(params.id as number, e.target.value)
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
@ -197,9 +196,9 @@ if (col.field === "userId") {
|
|||||||
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
|
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||||
"& .MuiSelect-select": { py: 0.5 },
|
"& .MuiSelect-select": { py: 0.5 },
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map(option => (
|
||||||
<MenuItem key={option.value} value={option.value}>
|
<MenuItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -218,7 +217,7 @@ if (col.field === "userId") {
|
|||||||
if (extraColumns && extraColumns.length > 0) {
|
if (extraColumns && extraColumns.length > 0) {
|
||||||
filteredColumns = showExtraColumns
|
filteredColumns = showExtraColumns
|
||||||
? tableColumns
|
? tableColumns
|
||||||
: tableColumns.filter((col) => !extraColumns.includes(col.field));
|
: tableColumns.filter(col => !extraColumns.includes(col.field));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -235,7 +234,7 @@ if (col.field === "userId") {
|
|||||||
label="Search"
|
label="Search"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
onChange={(e) => console.log(`setSearchQuery(${e.target.value})`)}
|
onChange={e => console.log(`setSearchQuery(${e.target.value})`)}
|
||||||
sx={{ width: 300 }}
|
sx={{ width: 300 }}
|
||||||
/>
|
/>
|
||||||
<AdvancedSearch labels={tableSearchLabels} />
|
<AdvancedSearch labels={tableSearchLabels} />
|
||||||
@ -250,7 +249,7 @@ if (col.field === "userId") {
|
|||||||
{extraColumns && extraColumns.length > 0 && (
|
{extraColumns && extraColumns.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => setShowExtraColumns((prev) => !prev)}
|
onClick={() => setShowExtraColumns(prev => !prev)}
|
||||||
>
|
>
|
||||||
{showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"}
|
{showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -281,7 +280,7 @@ if (col.field === "userId") {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onCellClick={(params) => {
|
onCellClick={params => {
|
||||||
if (params.field !== "actions") {
|
if (params.field !== "actions") {
|
||||||
handleClickField(params.field, params.value as string);
|
handleClickField(params.field, params.value as string);
|
||||||
}
|
}
|
||||||
@ -305,7 +304,7 @@ if (col.field === "userId") {
|
|||||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||||
<Select
|
<Select
|
||||||
value={fileType}
|
value={fileType}
|
||||||
onChange={(e) =>
|
onChange={e =>
|
||||||
setFileType(e.target.value as "csv" | "xls" | "xlsx")
|
setFileType(e.target.value as "csv" | "xls" | "xlsx")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -318,7 +317,7 @@ if (col.field === "userId") {
|
|||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={onlyCurrentTable}
|
checked={onlyCurrentTable}
|
||||||
onChange={(e) => setOnlyCurrentTable(e.target.checked)}
|
onChange={e => setOnlyCurrentTable(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Only export current table"
|
label="Only export current table"
|
||||||
@ -335,7 +334,7 @@ if (col.field === "userId") {
|
|||||||
tableColumns,
|
tableColumns,
|
||||||
fileType,
|
fileType,
|
||||||
onlyCurrentTable,
|
onlyCurrentTable,
|
||||||
setOpen,
|
setOpen
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ const StatusChangeDialog = ({
|
|||||||
multiline
|
multiline
|
||||||
rows={4}
|
rows={4}
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={e => setReason(e.target.value)}
|
||||||
helperText="Reason must be between 12 and 400 characters"
|
helperText="Reason must be between 12 and 400 characters"
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
.date-range-picker {
|
.date-range-picker {
|
||||||
.date-range-picker__date-typo {
|
.date-range-picker__date-typo {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export const DateRangePicker = () => {
|
|||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
const handleSelect: DateRangeProps["onChange"] = (ranges) => {
|
const handleSelect: DateRangeProps["onChange"] = ranges => {
|
||||||
if (ranges.selection) {
|
if (ranges.selection) {
|
||||||
setRange([ranges.selection]);
|
setRange([ranges.selection]);
|
||||||
if (ranges.selection.endDate !== ranges.selection.startDate) {
|
if (ranges.selection.endDate !== ranges.selection.startDate) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.documentation {
|
.documentation {
|
||||||
.documentation__icon {
|
.documentation__icon {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
.fetch-report {
|
.fetch-report {
|
||||||
padding: 23px;
|
padding: 23px;
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export const FetchReport = () => {
|
|||||||
<InputLabel>Select state (defaults to All)</InputLabel>
|
<InputLabel>Select state (defaults to All)</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={state}
|
value={state}
|
||||||
onChange={(e) => setState(e.target.value)}
|
onChange={e => setState(e.target.value)}
|
||||||
label="Select state (defaults to All)"
|
label="Select state (defaults to All)"
|
||||||
>
|
>
|
||||||
<MenuItem value="successful">Successful</MenuItem>
|
<MenuItem value="successful">Successful</MenuItem>
|
||||||
@ -67,7 +67,7 @@ export const FetchReport = () => {
|
|||||||
<InputLabel>Select PSPs (defaults to All)</InputLabel>
|
<InputLabel>Select PSPs (defaults to All)</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={psp}
|
value={psp}
|
||||||
onChange={(e) => setPsp(e.target.value)}
|
onChange={e => setPsp(e.target.value)}
|
||||||
label="Select PSPs (defaults to All)"
|
label="Select PSPs (defaults to All)"
|
||||||
>
|
>
|
||||||
<MenuItem value="a1">A1</MenuItem>
|
<MenuItem value="a1">A1</MenuItem>
|
||||||
@ -139,7 +139,7 @@ export const FetchReport = () => {
|
|||||||
<InputLabel>Select report type</InputLabel>
|
<InputLabel>Select report type</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={reportType}
|
value={reportType}
|
||||||
onChange={(e) => setReportType(e.target.value)}
|
onChange={e => setReportType(e.target.value)}
|
||||||
label="Select report type"
|
label="Select report type"
|
||||||
>
|
>
|
||||||
<MenuItem value="allTransactionsReport">
|
<MenuItem value="allTransactionsReport">
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
.general-health-card {
|
.general-health-card {
|
||||||
.general-health-card__header {
|
.general-health-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
||||||
.general-health-card__right-side {
|
.general-health-card__right-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.general-health-card__stat-items {
|
.general-health-card__stat-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
.static-item {
|
.static-item {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
|
|
||||||
.static-item__percentage {
|
.static-item__percentage {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
|||||||
|
|
||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
}, 400),
|
}, 400),
|
||||||
[router, searchParams, searchParamKey],
|
[router, searchParams, searchParamKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -102,13 +102,11 @@ export function ApproveTable<T extends { id: string | number }>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckboxChange = (id: string | number, checked: boolean) => {
|
const handleCheckboxChange = (id: string | number, checked: boolean) => {
|
||||||
setSelected((prev) =>
|
setSelected(prev => (checked ? [...prev, id] : prev.filter(x => x !== id)));
|
||||||
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleAll = (checked: boolean) => {
|
const handleToggleAll = (checked: boolean) => {
|
||||||
setSelected(checked ? rows.map((r) => r.id) : []);
|
setSelected(checked ? rows.map(r => r.id) : []);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActionChange = (e: SelectChangeEvent<string>) => {
|
const handleActionChange = (e: SelectChangeEvent<string>) => {
|
||||||
@ -125,7 +123,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
|||||||
|
|
||||||
const handleStatusSave = () => {
|
const handleStatusSave = () => {
|
||||||
console.log(
|
console.log(
|
||||||
`Status changed for row with ID ${selected}. New status: ${action}. Reason: ${reason}`,
|
`Status changed for row with ID ${selected}. New status: ${action}. Reason: ${reason}`
|
||||||
);
|
);
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setReason("");
|
setReason("");
|
||||||
@ -177,7 +175,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
|||||||
onChange={handleActionChange}
|
onChange={handleActionChange}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{actions.map((item) => (
|
{actions.map(item => (
|
||||||
<MenuItem key={item.value} value={item.value}>
|
<MenuItem key={item.value} value={item.value}>
|
||||||
{item.label ?? item.value}
|
{item.label ?? item.value}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -219,7 +217,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
|||||||
indeterminate={
|
indeterminate={
|
||||||
selected.length > 0 && selected.length < rows.length
|
selected.length > 0 && selected.length < rows.length
|
||||||
}
|
}
|
||||||
onChange={(e) => handleToggleAll(e.target.checked)}
|
onChange={e => handleToggleAll(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
@ -257,7 +255,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
|||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.includes(row.id)}
|
checked={selected.includes(row.id)}
|
||||||
onChange={(e) =>
|
onChange={e =>
|
||||||
handleCheckboxChange(row.id, e.target.checked)
|
handleCheckboxChange(row.id, e.target.checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -304,7 +302,9 @@ export function ApproveTable<T extends { id: string | number }>({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{textAlign: "center"}}>{value as React.ReactNode}</Box>
|
<Box sx={{ textAlign: "center" }}>
|
||||||
|
{value as React.ReactNode}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
57
app/features/Pages/Settings/SettingsAccountSecurity.tsx
Normal file
57
app/features/Pages/Settings/SettingsAccountSecurity.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Paper, Typography } from "@mui/material";
|
||||||
|
import { ChangePassword } from "@/app/features/Auth/ChangePassword/ChangePassword";
|
||||||
|
import { RootState } from "@/app/redux/store";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { AppDispatch } from "@/app/redux/types";
|
||||||
|
import { changePassword } from "@/app/redux/auth/authSlice";
|
||||||
|
|
||||||
|
const SettingsAccountSecurity: React.FC = () => {
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const user = useSelector((state: RootState) => state.auth.user);
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
|
||||||
|
const handleChangePassword = async (passwordData: {
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
if (!user?.email) {
|
||||||
|
throw new Error("User email not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatch(
|
||||||
|
changePassword({
|
||||||
|
email: user.email,
|
||||||
|
newPassword: passwordData.newPassword,
|
||||||
|
currentPassword: passwordData.currentPassword,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
// Error handling is now done by the epic
|
||||||
|
console.error("Password change error:", err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={1} sx={{ p: 3, maxWidth: 520 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Account Security
|
||||||
|
</Typography>
|
||||||
|
<ChangePassword
|
||||||
|
isUserChange={true}
|
||||||
|
open={!submitting}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSubmit={handleChangePassword}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsAccountSecurity;
|
||||||
39
app/features/Pages/Settings/SettingsPageClient.tsx
Normal file
39
app/features/Pages/Settings/SettingsPageClient.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Box, Typography } from "@mui/material";
|
||||||
|
import SettingsAccountSecurity from "./SettingsAccountSecurity";
|
||||||
|
import SettingsPersonalInfo from "./SettingsPersonalInfo";
|
||||||
|
import SettingsSidebar from "./SettingsSidebar";
|
||||||
|
|
||||||
|
const SettingsPageClient: React.FC = () => {
|
||||||
|
const [activeSection, setActiveSection] = useState<"personal" | "account">(
|
||||||
|
"personal"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
|
<Typography variant="h5" fontWeight={600}>
|
||||||
|
Settings
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 3 }}>
|
||||||
|
<SettingsSidebar
|
||||||
|
active={activeSection}
|
||||||
|
onChange={(
|
||||||
|
section:
|
||||||
|
| string
|
||||||
|
| ((prevState: "personal" | "account") => "personal" | "account")
|
||||||
|
) => setActiveSection(section)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
{activeSection === "personal" && <SettingsPersonalInfo />}
|
||||||
|
{activeSection === "account" && <SettingsAccountSecurity />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPageClient;
|
||||||
105
app/features/Pages/Settings/SettingsPersonalInfo.tsx
Normal file
105
app/features/Pages/Settings/SettingsPersonalInfo.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Divider,
|
||||||
|
Button,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { AppDispatch, RootState } from "@/app/redux/store";
|
||||||
|
import { updateUserDetails } from "@/app/redux/auth/authSlice";
|
||||||
|
|
||||||
|
const SettingsPersonalInfo: React.FC = () => {
|
||||||
|
const user = useSelector((state: RootState) => state.auth.user);
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize form fields from Redux user data
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setFormData({
|
||||||
|
first_name: user.firstName ?? "",
|
||||||
|
last_name: user.lastName ?? "",
|
||||||
|
username: user.username ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Generic change handler
|
||||||
|
const handleChange =
|
||||||
|
(field: keyof typeof formData) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUpdateUserDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUserDetails = () => {
|
||||||
|
dispatch(updateUserDetails({ id: user?.id ?? "", updates: formData }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={1} sx={{ p: 3, maxWidth: 720 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Personal Info
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={handleSave}
|
||||||
|
sx={{ display: "flex", flexDirection: "column", gap: 2 }}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="First Name"
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={handleChange("first_name")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Last Name"
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={handleChange("last_name")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange("username")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={true}
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange("email")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPersonalInfo;
|
||||||
46
app/features/Pages/Settings/SettingsSidebar.tsx
Normal file
46
app/features/Pages/Settings/SettingsSidebar.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Paper,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
||||||
|
import SecurityIcon from "@mui/icons-material/Security";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
active: "personal" | "account";
|
||||||
|
onChange: (section: "personal" | "account") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsSidebar: React.FC<Props> = ({ active, onChange }) => {
|
||||||
|
return (
|
||||||
|
<Paper elevation={1} sx={{ width: 260, p: 1 }}>
|
||||||
|
<List component="nav">
|
||||||
|
<ListItemButton
|
||||||
|
selected={active === "personal"}
|
||||||
|
onClick={() => onChange("personal")}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<AccountCircleIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Personal Info" />
|
||||||
|
</ListItemButton>
|
||||||
|
<ListItemButton
|
||||||
|
selected={active === "account"}
|
||||||
|
onClick={() => onChange("account")}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SecurityIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Account Security" />
|
||||||
|
</ListItemButton>
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsSidebar;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
.pie-charts {
|
.pie-charts {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
width: 60%;
|
width: 60%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,20 @@
|
|||||||
.section-card {
|
.section-card {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
.section-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
.section-card__header {
|
margin-bottom: 16px;
|
||||||
display: flex;
|
.section-card__title {
|
||||||
justify-content: space-between;
|
display: flex;
|
||||||
margin-bottom: 16px;
|
gap: 16px;
|
||||||
.section-card__title {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.section-card__icon-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.section-card__icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,28 @@
|
|||||||
.transaction-overview {
|
.transaction-overview {
|
||||||
padding: 23px;
|
padding: 23px;
|
||||||
|
margin: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.transaction-overview__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-overview__chart-table {
|
||||||
|
padding: 16px;
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.transaction-overview__header {
|
@media (min-width: 960px) {
|
||||||
display: flex;
|
flex-wrap: nowrap;
|
||||||
justify-content: space-between;
|
gap: 0;
|
||||||
align-items: center;
|
|
||||||
padding-left: 8px;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transaction-overview__chart-table {
|
|
||||||
padding: 16px;
|
|
||||||
margin: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 32px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
.transactions-overview-table {
|
.transactions-overview-table {
|
||||||
.transactions-overview-table__state-wrapper {
|
.transactions-overview-table__state-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
width: 73px;
|
width: 73px;
|
||||||
|
|
||||||
.transactions-overview-table__state {
|
.transactions-overview-table__state {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
.transactions-waiting-approval {
|
.transactions-waiting-approval {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useRouter } from "next/navigation";
|
|||||||
import "./AddUser.scss";
|
import "./AddUser.scss";
|
||||||
import { addUser } from "@/services/roles.services";
|
import { addUser } from "@/services/roles.services";
|
||||||
import { IEditUserForm } from "../User.interfaces";
|
import { IEditUserForm } from "../User.interfaces";
|
||||||
|
import { COUNTRY_CODES } from "../constants";
|
||||||
|
import { formatPhoneDisplay, validatePhone } from "../utils";
|
||||||
|
|
||||||
interface AddUserFormProps {
|
interface AddUserFormProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
@ -13,27 +15,81 @@ interface AddUserFormProps {
|
|||||||
const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [form, setForm] = useState<IEditUserForm>({
|
const [form, setForm] = useState<IEditUserForm>({
|
||||||
|
username: "",
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
role: "",
|
role: "",
|
||||||
|
merchants: [],
|
||||||
|
groups: [],
|
||||||
|
jobTitle: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [phoneError, setPhoneError] = useState("");
|
||||||
|
const [countryCode, setCountryCode] = useState("+1");
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
) => {
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setForm((prev) => ({ ...prev, [name]: value }));
|
|
||||||
|
if (name === "countryCode") {
|
||||||
|
setCountryCode(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array fields (merchants and groups)
|
||||||
|
if (name === "merchants" || name === "groups") {
|
||||||
|
if (value === "") {
|
||||||
|
// If empty selection, set empty array
|
||||||
|
setForm(prev => ({ ...prev, [name]: [] }));
|
||||||
|
} else {
|
||||||
|
// Add the selected value to the array if not already present
|
||||||
|
setForm(prev => {
|
||||||
|
const currentArray = prev[name as keyof IEditUserForm] as string[];
|
||||||
|
if (!currentArray.includes(value)) {
|
||||||
|
return { ...prev, [name]: [...currentArray, value] };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle single value fields
|
||||||
|
setForm(prev => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCountryCodeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const newCountryCode = e.target.value;
|
||||||
|
setCountryCode(newCountryCode);
|
||||||
|
|
||||||
|
// Re-validate phone if it exists
|
||||||
|
if (form.phone) {
|
||||||
|
const phoneError = validatePhone(form.phone, newCountryCode);
|
||||||
|
setPhoneError(phoneError);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!form.firstName || !form.lastName || !form.email || !form.role) {
|
// Validate phone number if provided
|
||||||
|
if (form.phone && phoneError) {
|
||||||
|
setError("Please fix phone number errors before submitting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!form.firstName ||
|
||||||
|
!form.lastName ||
|
||||||
|
!form.email ||
|
||||||
|
form.merchants.length === 0 ||
|
||||||
|
form.groups.length === 0 ||
|
||||||
|
!form.jobTitle
|
||||||
|
) {
|
||||||
setError("Please fill in all required fields.");
|
setError("Please fill in all required fields.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -42,7 +98,13 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
await addUser(form);
|
// Format phone number with country code before submission
|
||||||
|
const formattedForm = {
|
||||||
|
...form,
|
||||||
|
phone: form.phone ? formatPhoneDisplay(form.phone, countryCode) : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
await addUser(formattedForm);
|
||||||
if (onSuccess) onSuccess();
|
if (onSuccess) onSuccess();
|
||||||
router.refresh(); // <- refreshes the page (SSR re-runs)
|
router.refresh(); // <- refreshes the page (SSR re-runs)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -55,6 +117,13 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="add-user" onSubmit={handleSubmit}>
|
<form className="add-user" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
name="username"
|
||||||
|
placeholder="Username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
name="firstName"
|
name="firstName"
|
||||||
placeholder="First Name"
|
placeholder="First Name"
|
||||||
@ -77,26 +146,126 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<input
|
<div className="array-field-container">
|
||||||
name="phone"
|
<label>Merchants:</label>
|
||||||
placeholder="Phone (optional)"
|
<select
|
||||||
value={form.phone}
|
name="merchants"
|
||||||
onChange={handleChange}
|
value=""
|
||||||
/>
|
onChange={handleChange}
|
||||||
|
className="add-user__select"
|
||||||
|
>
|
||||||
|
<option value="">Select Merchant</option>
|
||||||
|
<option value="Win Bot">Win Bot</option>
|
||||||
|
<option value="Data Spin">Data Spin</option>
|
||||||
|
</select>
|
||||||
|
{form.merchants.length > 0 && (
|
||||||
|
<div className="selected-items">
|
||||||
|
{form.merchants.map((merchant, index) => (
|
||||||
|
<span key={index} className="selected-item">
|
||||||
|
{merchant}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
merchants: prev.merchants.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="remove-item"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="array-field-container">
|
||||||
|
<label>Groups:</label>
|
||||||
|
<select
|
||||||
|
name="groups"
|
||||||
|
value=""
|
||||||
|
onChange={handleChange}
|
||||||
|
className="add-user__select"
|
||||||
|
>
|
||||||
|
<option value="">Select Group</option>
|
||||||
|
<option value="Admin">Admin</option>
|
||||||
|
<option value="Reader">Reader</option>
|
||||||
|
<option value="Manager">Manager</option>
|
||||||
|
<option value="User">User</option>
|
||||||
|
</select>
|
||||||
|
{form.groups.length > 0 && (
|
||||||
|
<div className="selected-items">
|
||||||
|
{form.groups.map((group, index) => (
|
||||||
|
<span key={index} className="selected-item">
|
||||||
|
{group}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
groups: prev.groups.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="remove-item"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
name="role"
|
name="jobTitle"
|
||||||
value={form.role}
|
value={form.jobTitle}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
className="add-user__select"
|
className="add-user__select"
|
||||||
>
|
>
|
||||||
<option value="">Select Role</option>
|
<option value="">Select Job Title</option>
|
||||||
<option value="ROLE_IIN">ROLE_IIN</option>
|
<option value="Admin">Admin</option>
|
||||||
<option value="ROLE_RULES_ADMIN">ROLE_RULES_ADMIN</option>
|
<option value="Reader">Reader</option>
|
||||||
<option value="ROLE_FIRST_APPROVER">ROLE_FIRST_APPROVER</option>
|
<option value="User">User</option>
|
||||||
|
<option value="Manager">Manager</option>
|
||||||
|
<option value="Supervisor">Supervisor</option>
|
||||||
|
<option value="Director">Director</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div className="phone-input-container">
|
||||||
|
<select
|
||||||
|
name="countryCode"
|
||||||
|
value={countryCode}
|
||||||
|
onChange={handleCountryCodeChange}
|
||||||
|
className="country-code-select"
|
||||||
|
>
|
||||||
|
{COUNTRY_CODES.map(country => (
|
||||||
|
<option key={country.code} value={country.code}>
|
||||||
|
{country.flag} {country.code} {country.country}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="Phone number (optional)"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={phoneError ? "phone-input-error" : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div style={{ color: "red", width: "100%" }}>{error}</div>}
|
{error && <span style={{ color: "red", width: "100%" }}>{error}</span>}
|
||||||
|
<span
|
||||||
|
className="phone-error-message"
|
||||||
|
style={{
|
||||||
|
visibility: phoneError ? "visible" : "hidden",
|
||||||
|
color: "red",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phoneError}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="add-user__button-container">
|
<div className="add-user__button-container">
|
||||||
<button type="submit" disabled={loading}>
|
<button type="submit" disabled={loading}>
|
||||||
|
|||||||
@ -21,12 +21,12 @@ const EditUser = ({ user }: { user: IUser }) => {
|
|||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (name === "phone") {
|
if (name === "phone") {
|
||||||
const filtered = value.replace(/[^0-9+\-\s()]/g, "");
|
const filtered = value.replace(/[^0-9+\-\s()]/g, "");
|
||||||
setForm((prev) => ({
|
setForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
phone: filtered,
|
phone: filtered,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
setForm((prev) => ({
|
setForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[name]: value,
|
[name]: value,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -1,12 +1,20 @@
|
|||||||
export interface IEditUserForm {
|
export interface IEditUserForm {
|
||||||
|
username: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
merchants: string[];
|
||||||
|
groups: string[];
|
||||||
|
jobTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EditUserField =
|
export type EditUserField =
|
||||||
|
| "merchants"
|
||||||
|
| "groups"
|
||||||
|
| "jobTitle"
|
||||||
|
| "username"
|
||||||
| "firstName"
|
| "firstName"
|
||||||
| "lastName"
|
| "lastName"
|
||||||
| "email"
|
| "email"
|
||||||
|
|||||||
217
app/features/UserRoles/constants.ts
Normal file
217
app/features/UserRoles/constants.ts
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
// Country codes for phone prefixes
|
||||||
|
export const COUNTRY_CODES = [
|
||||||
|
{ code: "+1", country: "US/Canada", flag: "🇺🇸" },
|
||||||
|
{ code: "+44", country: "UK", flag: "🇬🇧" },
|
||||||
|
{ code: "+49", country: "Germany", flag: "🇩🇪" },
|
||||||
|
{ code: "+33", country: "France", flag: "🇫🇷" },
|
||||||
|
{ code: "+39", country: "Italy", flag: "🇮🇹" },
|
||||||
|
{ code: "+34", country: "Spain", flag: "🇪🇸" },
|
||||||
|
{ code: "+31", country: "Netherlands", flag: "🇳🇱" },
|
||||||
|
{ code: "+32", country: "Belgium", flag: "🇧🇪" },
|
||||||
|
{ code: "+41", country: "Switzerland", flag: "🇨🇭" },
|
||||||
|
{ code: "+43", country: "Austria", flag: "🇦🇹" },
|
||||||
|
{ code: "+45", country: "Denmark", flag: "🇩🇰" },
|
||||||
|
{ code: "+46", country: "Sweden", flag: "🇸🇪" },
|
||||||
|
{ code: "+47", country: "Norway", flag: "🇳🇴" },
|
||||||
|
{ code: "+358", country: "Finland", flag: "🇫🇮" },
|
||||||
|
{ code: "+48", country: "Poland", flag: "🇵🇱" },
|
||||||
|
{ code: "+420", country: "Czech Republic", flag: "🇨🇿" },
|
||||||
|
{ code: "+36", country: "Hungary", flag: "🇭🇺" },
|
||||||
|
{ code: "+40", country: "Romania", flag: "🇷🇴" },
|
||||||
|
{ code: "+359", country: "Bulgaria", flag: "🇧🇬" },
|
||||||
|
{ code: "+385", country: "Croatia", flag: "🇭🇷" },
|
||||||
|
{ code: "+386", country: "Slovenia", flag: "🇸🇮" },
|
||||||
|
{ code: "+421", country: "Slovakia", flag: "🇸🇰" },
|
||||||
|
{ code: "+370", country: "Lithuania", flag: "🇱🇹" },
|
||||||
|
{ code: "+371", country: "Latvia", flag: "🇱🇻" },
|
||||||
|
{ code: "+372", country: "Estonia", flag: "🇪🇪" },
|
||||||
|
{ code: "+353", country: "Ireland", flag: "🇮🇪" },
|
||||||
|
{ code: "+351", country: "Portugal", flag: "🇵🇹" },
|
||||||
|
{ code: "+30", country: "Greece", flag: "🇬🇷" },
|
||||||
|
{ code: "+357", country: "Cyprus", flag: "🇨🇾" },
|
||||||
|
{ code: "+356", country: "Malta", flag: "🇲🇹" },
|
||||||
|
{ code: "+352", country: "Luxembourg", flag: "🇱🇺" },
|
||||||
|
{ code: "+7", country: "Russia", flag: "🇷🇺" },
|
||||||
|
{ code: "+380", country: "Ukraine", flag: "🇺🇦" },
|
||||||
|
{ code: "+375", country: "Belarus", flag: "🇧🇾" },
|
||||||
|
{ code: "+370", country: "Lithuania", flag: "🇱🇹" },
|
||||||
|
{ code: "+371", country: "Latvia", flag: "🇱🇻" },
|
||||||
|
{ code: "+372", country: "Estonia", flag: "🇪🇪" },
|
||||||
|
{ code: "+81", country: "Japan", flag: "🇯🇵" },
|
||||||
|
{ code: "+82", country: "South Korea", flag: "🇰🇷" },
|
||||||
|
{ code: "+86", country: "China", flag: "🇨🇳" },
|
||||||
|
{ code: "+91", country: "India", flag: "🇮🇳" },
|
||||||
|
{ code: "+61", country: "Australia", flag: "🇦🇺" },
|
||||||
|
{ code: "+64", country: "New Zealand", flag: "🇳🇿" },
|
||||||
|
{ code: "+55", country: "Brazil", flag: "🇧🇷" },
|
||||||
|
{ code: "+52", country: "Mexico", flag: "🇲🇽" },
|
||||||
|
{ code: "+54", country: "Argentina", flag: "🇦🇷" },
|
||||||
|
{ code: "+56", country: "Chile", flag: "🇨🇱" },
|
||||||
|
{ code: "+57", country: "Colombia", flag: "🇨🇴" },
|
||||||
|
{ code: "+51", country: "Peru", flag: "🇵🇪" },
|
||||||
|
{ code: "+58", country: "Venezuela", flag: "🇻🇪" },
|
||||||
|
{ code: "+27", country: "South Africa", flag: "🇿🇦" },
|
||||||
|
{ code: "+20", country: "Egypt", flag: "🇪🇬" },
|
||||||
|
{ code: "+234", country: "Nigeria", flag: "🇳🇬" },
|
||||||
|
{ code: "+254", country: "Kenya", flag: "🇰🇪" },
|
||||||
|
{ code: "+233", country: "Ghana", flag: "🇬🇭" },
|
||||||
|
{ code: "+212", country: "Morocco", flag: "🇲🇦" },
|
||||||
|
{ code: "+213", country: "Algeria", flag: "🇩🇿" },
|
||||||
|
{ code: "+216", country: "Tunisia", flag: "🇹🇳" },
|
||||||
|
{ code: "+218", country: "Libya", flag: "🇱🇾" },
|
||||||
|
{ code: "+220", country: "Gambia", flag: "🇬🇲" },
|
||||||
|
{ code: "+221", country: "Senegal", flag: "🇸🇳" },
|
||||||
|
{ code: "+222", country: "Mauritania", flag: "🇲🇷" },
|
||||||
|
{ code: "+223", country: "Mali", flag: "🇲🇱" },
|
||||||
|
{ code: "+224", country: "Guinea", flag: "🇬🇳" },
|
||||||
|
{ code: "+225", country: "Ivory Coast", flag: "🇨🇮" },
|
||||||
|
{ code: "+226", country: "Burkina Faso", flag: "🇧🇫" },
|
||||||
|
{ code: "+227", country: "Niger", flag: "🇳🇪" },
|
||||||
|
{ code: "+228", country: "Togo", flag: "🇹🇬" },
|
||||||
|
{ code: "+229", country: "Benin", flag: "🇧🇯" },
|
||||||
|
{ code: "+230", country: "Mauritius", flag: "🇲🇺" },
|
||||||
|
{ code: "+231", country: "Liberia", flag: "🇱🇷" },
|
||||||
|
{ code: "+232", country: "Sierra Leone", flag: "🇸🇱" },
|
||||||
|
{ code: "+235", country: "Chad", flag: "🇹🇩" },
|
||||||
|
{ code: "+236", country: "Central African Republic", flag: "🇨🇫" },
|
||||||
|
{ code: "+237", country: "Cameroon", flag: "🇨🇲" },
|
||||||
|
{ code: "+238", country: "Cape Verde", flag: "🇨🇻" },
|
||||||
|
{ code: "+239", country: "São Tomé and Príncipe", flag: "🇸🇹" },
|
||||||
|
{ code: "+240", country: "Equatorial Guinea", flag: "🇬🇶" },
|
||||||
|
{ code: "+241", country: "Gabon", flag: "🇬🇦" },
|
||||||
|
{ code: "+242", country: "Republic of the Congo", flag: "🇨🇬" },
|
||||||
|
{ code: "+243", country: "Democratic Republic of the Congo", flag: "🇨🇩" },
|
||||||
|
{ code: "+244", country: "Angola", flag: "🇦🇴" },
|
||||||
|
{ code: "+245", country: "Guinea-Bissau", flag: "🇬🇼" },
|
||||||
|
{ code: "+246", country: "British Indian Ocean Territory", flag: "🇮🇴" },
|
||||||
|
{ code: "+248", country: "Seychelles", flag: "🇸🇨" },
|
||||||
|
{ code: "+249", country: "Sudan", flag: "🇸🇩" },
|
||||||
|
{ code: "+250", country: "Rwanda", flag: "🇷🇼" },
|
||||||
|
{ code: "+251", country: "Ethiopia", flag: "🇪🇹" },
|
||||||
|
{ code: "+252", country: "Somalia", flag: "🇸🇴" },
|
||||||
|
{ code: "+253", country: "Djibouti", flag: "🇩🇯" },
|
||||||
|
{ code: "+255", country: "Tanzania", flag: "🇹🇿" },
|
||||||
|
{ code: "+256", country: "Uganda", flag: "🇺🇬" },
|
||||||
|
{ code: "+257", country: "Burundi", flag: "🇧🇮" },
|
||||||
|
{ code: "+258", country: "Mozambique", flag: "🇲🇿" },
|
||||||
|
{ code: "+260", country: "Zambia", flag: "🇿🇲" },
|
||||||
|
{ code: "+261", country: "Madagascar", flag: "🇲🇬" },
|
||||||
|
{ code: "+262", country: "Réunion", flag: "🇷🇪" },
|
||||||
|
{ code: "+263", country: "Zimbabwe", flag: "🇿🇼" },
|
||||||
|
{ code: "+264", country: "Namibia", flag: "🇳🇦" },
|
||||||
|
{ code: "+265", country: "Malawi", flag: "🇲🇼" },
|
||||||
|
{ code: "+266", country: "Lesotho", flag: "🇱🇸" },
|
||||||
|
{ code: "+267", country: "Botswana", flag: "🇧🇼" },
|
||||||
|
{ code: "+268", country: "Swaziland", flag: "🇸🇿" },
|
||||||
|
{ code: "+269", country: "Comoros", flag: "🇰🇲" },
|
||||||
|
{ code: "+290", country: "Saint Helena", flag: "🇸🇭" },
|
||||||
|
{ code: "+291", country: "Eritrea", flag: "🇪🇷" },
|
||||||
|
{ code: "+297", country: "Aruba", flag: "🇦🇼" },
|
||||||
|
{ code: "+298", country: "Faroe Islands", flag: "🇫🇴" },
|
||||||
|
{ code: "+299", country: "Greenland", flag: "🇬🇱" },
|
||||||
|
{ code: "+350", country: "Gibraltar", flag: "🇬🇮" },
|
||||||
|
{ code: "+351", country: "Portugal", flag: "🇵🇹" },
|
||||||
|
{ code: "+352", country: "Luxembourg", flag: "🇱🇺" },
|
||||||
|
{ code: "+353", country: "Ireland", flag: "🇮🇪" },
|
||||||
|
{ code: "+354", country: "Iceland", flag: "🇮🇸" },
|
||||||
|
{ code: "+355", country: "Albania", flag: "🇦🇱" },
|
||||||
|
{ code: "+356", country: "Malta", flag: "🇲🇹" },
|
||||||
|
{ code: "+357", country: "Cyprus", flag: "🇨🇾" },
|
||||||
|
{ code: "+358", country: "Finland", flag: "🇫🇮" },
|
||||||
|
{ code: "+359", country: "Bulgaria", flag: "🇧🇬" },
|
||||||
|
{ code: "+370", country: "Lithuania", flag: "🇱🇹" },
|
||||||
|
{ code: "+371", country: "Latvia", flag: "🇱🇻" },
|
||||||
|
{ code: "+372", country: "Estonia", flag: "🇪🇪" },
|
||||||
|
{ code: "+373", country: "Moldova", flag: "🇲🇩" },
|
||||||
|
{ code: "+374", country: "Armenia", flag: "🇦🇲" },
|
||||||
|
{ code: "+375", country: "Belarus", flag: "🇧🇾" },
|
||||||
|
{ code: "+376", country: "Andorra", flag: "🇦🇩" },
|
||||||
|
{ code: "+377", country: "Monaco", flag: "🇲🇨" },
|
||||||
|
{ code: "+378", country: "San Marino", flag: "🇸🇲" },
|
||||||
|
{ code: "+380", country: "Ukraine", flag: "🇺🇦" },
|
||||||
|
{ code: "+381", country: "Serbia", flag: "🇷🇸" },
|
||||||
|
{ code: "+382", country: "Montenegro", flag: "🇲🇪" },
|
||||||
|
{ code: "+383", country: "Kosovo", flag: "🇽🇰" },
|
||||||
|
{ code: "+385", country: "Croatia", flag: "🇭🇷" },
|
||||||
|
{ code: "+386", country: "Slovenia", flag: "🇸🇮" },
|
||||||
|
{ code: "+387", country: "Bosnia and Herzegovina", flag: "🇧🇦" },
|
||||||
|
{ code: "+389", country: "North Macedonia", flag: "🇲🇰" },
|
||||||
|
{ code: "+420", country: "Czech Republic", flag: "🇨🇿" },
|
||||||
|
{ code: "+421", country: "Slovakia", flag: "🇸🇰" },
|
||||||
|
{ code: "+423", country: "Liechtenstein", flag: "🇱🇮" },
|
||||||
|
{ code: "+500", country: "Falkland Islands", flag: "🇫🇰" },
|
||||||
|
{ code: "+501", country: "Belize", flag: "🇧🇿" },
|
||||||
|
{ code: "+502", country: "Guatemala", flag: "🇬🇹" },
|
||||||
|
{ code: "+503", country: "El Salvador", flag: "🇸🇻" },
|
||||||
|
{ code: "+504", country: "Honduras", flag: "🇭🇳" },
|
||||||
|
{ code: "+505", country: "Nicaragua", flag: "🇳🇮" },
|
||||||
|
{ code: "+506", country: "Costa Rica", flag: "🇨🇷" },
|
||||||
|
{ code: "+507", country: "Panama", flag: "🇵🇦" },
|
||||||
|
{ code: "+508", country: "Saint Pierre and Miquelon", flag: "🇵🇲" },
|
||||||
|
{ code: "+509", country: "Haiti", flag: "🇭🇹" },
|
||||||
|
{ code: "+590", country: "Guadeloupe", flag: "🇬🇵" },
|
||||||
|
{ code: "+591", country: "Bolivia", flag: "🇧🇴" },
|
||||||
|
{ code: "+592", country: "Guyana", flag: "🇬🇾" },
|
||||||
|
{ code: "+593", country: "Ecuador", flag: "🇪🇨" },
|
||||||
|
{ code: "+594", country: "French Guiana", flag: "🇬🇫" },
|
||||||
|
{ code: "+595", country: "Paraguay", flag: "🇵🇾" },
|
||||||
|
{ code: "+596", country: "Martinique", flag: "🇲🇶" },
|
||||||
|
{ code: "+597", country: "Suriname", flag: "🇸🇷" },
|
||||||
|
{ code: "+598", country: "Uruguay", flag: "🇺🇾" },
|
||||||
|
{ code: "+599", country: "Netherlands Antilles", flag: "🇧🇶" },
|
||||||
|
{ code: "+670", country: "East Timor", flag: "🇹🇱" },
|
||||||
|
{ code: "+672", country: "Australian External Territories", flag: "🇦🇶" },
|
||||||
|
{ code: "+673", country: "Brunei", flag: "🇧🇳" },
|
||||||
|
{ code: "+674", country: "Nauru", flag: "🇳🇷" },
|
||||||
|
{ code: "+675", country: "Papua New Guinea", flag: "🇵🇬" },
|
||||||
|
{ code: "+676", country: "Tonga", flag: "🇹🇴" },
|
||||||
|
{ code: "+677", country: "Solomon Islands", flag: "🇸🇧" },
|
||||||
|
{ code: "+678", country: "Vanuatu", flag: "🇻🇺" },
|
||||||
|
{ code: "+679", country: "Fiji", flag: "🇫🇯" },
|
||||||
|
{ code: "+680", country: "Palau", flag: "🇵🇼" },
|
||||||
|
{ code: "+681", country: "Wallis and Futuna", flag: "🇼🇫" },
|
||||||
|
{ code: "+682", country: "Cook Islands", flag: "🇨🇰" },
|
||||||
|
{ code: "+683", country: "Niue", flag: "🇳🇺" },
|
||||||
|
{ code: "+684", country: "American Samoa", flag: "🇦🇸" },
|
||||||
|
{ code: "+685", country: "Samoa", flag: "🇼🇸" },
|
||||||
|
{ code: "+686", country: "Kiribati", flag: "🇰🇮" },
|
||||||
|
{ code: "+687", country: "New Caledonia", flag: "🇳🇨" },
|
||||||
|
{ code: "+688", country: "Tuvalu", flag: "🇹🇻" },
|
||||||
|
{ code: "+689", country: "French Polynesia", flag: "🇵🇫" },
|
||||||
|
{ code: "+690", country: "Tokelau", flag: "🇹🇰" },
|
||||||
|
{ code: "+691", country: "Micronesia", flag: "🇫🇲" },
|
||||||
|
{ code: "+692", country: "Marshall Islands", flag: "🇲🇭" },
|
||||||
|
{ code: "+850", country: "North Korea", flag: "🇰🇵" },
|
||||||
|
{ code: "+852", country: "Hong Kong", flag: "🇭🇰" },
|
||||||
|
{ code: "+853", country: "Macau", flag: "🇲🇴" },
|
||||||
|
{ code: "+855", country: "Cambodia", flag: "🇰🇭" },
|
||||||
|
{ code: "+856", country: "Laos", flag: "🇱🇦" },
|
||||||
|
{ code: "+880", country: "Bangladesh", flag: "🇧🇩" },
|
||||||
|
{ code: "+886", country: "Taiwan", flag: "🇹🇼" },
|
||||||
|
{ code: "+960", country: "Maldives", flag: "🇲🇻" },
|
||||||
|
{ code: "+961", country: "Lebanon", flag: "🇱🇧" },
|
||||||
|
{ code: "+962", country: "Jordan", flag: "🇯🇴" },
|
||||||
|
{ code: "+963", country: "Syria", flag: "🇸🇾" },
|
||||||
|
{ code: "+964", country: "Iraq", flag: "🇮🇶" },
|
||||||
|
{ code: "+965", country: "Kuwait", flag: "🇰🇼" },
|
||||||
|
{ code: "+966", country: "Saudi Arabia", flag: "🇸🇦" },
|
||||||
|
{ code: "+967", country: "Yemen", flag: "🇾🇪" },
|
||||||
|
{ code: "+968", country: "Oman", flag: "🇴🇲" },
|
||||||
|
{ code: "+970", country: "Palestine", flag: "🇵🇸" },
|
||||||
|
{ code: "+971", country: "United Arab Emirates", flag: "🇦🇪" },
|
||||||
|
{ code: "+972", country: "Israel", flag: "🇮🇱" },
|
||||||
|
{ code: "+973", country: "Bahrain", flag: "🇧🇭" },
|
||||||
|
{ code: "+974", country: "Qatar", flag: "🇶🇦" },
|
||||||
|
{ code: "+975", country: "Bhutan", flag: "🇧🇹" },
|
||||||
|
{ code: "+976", country: "Mongolia", flag: "🇲🇳" },
|
||||||
|
{ code: "+977", country: "Nepal", flag: "🇳🇵" },
|
||||||
|
{ code: "+992", country: "Tajikistan", flag: "🇹🇯" },
|
||||||
|
{ code: "+993", country: "Turkmenistan", flag: "🇹🇲" },
|
||||||
|
{ code: "+994", country: "Azerbaijan", flag: "🇦🇿" },
|
||||||
|
{ code: "+995", country: "Georgia", flag: "🇬🇪" },
|
||||||
|
{ code: "+996", country: "Kyrgyzstan", flag: "🇰🇬" },
|
||||||
|
{ code: "+998", country: "Uzbekistan", flag: "🇺🇿" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Phone number validation regex
|
||||||
|
export const PHONE_REGEX = /^[\+]?[1-9][\d]{0,15}$/;
|
||||||
@ -100,7 +100,7 @@ export default function UserRoleCard({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} mt={1} flexWrap="wrap">
|
<Stack direction="row" spacing={1} mt={1} flexWrap="wrap">
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
{roles.map((role) => (
|
{roles.map(role => (
|
||||||
<Chip key={role} label={role} size="small" />
|
<Chip key={role} label={role} size="small" />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
49
app/features/UserRoles/utils.ts
Normal file
49
app/features/UserRoles/utils.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { PHONE_REGEX } from "./constants";
|
||||||
|
|
||||||
|
// Phone validation function
|
||||||
|
export const validatePhone = (phone: string, countryCode: string): string => {
|
||||||
|
if (!phone) return ""; // Phone is optional
|
||||||
|
|
||||||
|
// Remove any non-digit characters except +
|
||||||
|
const cleanPhone = phone.replace(/[^\d+]/g, "");
|
||||||
|
|
||||||
|
// Check if phone starts with country code
|
||||||
|
if (cleanPhone.startsWith(countryCode)) {
|
||||||
|
const phoneNumber = cleanPhone.substring(countryCode.length);
|
||||||
|
if (phoneNumber.length < 7 || phoneNumber.length > 15) {
|
||||||
|
return "Phone number must be between 7-15 digits";
|
||||||
|
}
|
||||||
|
if (!PHONE_REGEX.test(phoneNumber)) {
|
||||||
|
return "Invalid phone number format";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If phone doesn't start with country code, validate the whole number
|
||||||
|
if (cleanPhone.length < 7 || cleanPhone.length > 15) {
|
||||||
|
return "Phone number must be between 7-15 digits";
|
||||||
|
}
|
||||||
|
if (!PHONE_REGEX.test(cleanPhone)) {
|
||||||
|
return "Invalid phone number format";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format phone number display
|
||||||
|
export const formatPhoneDisplay = (
|
||||||
|
phone: string,
|
||||||
|
countryCode: string
|
||||||
|
): string => {
|
||||||
|
if (!phone) return "";
|
||||||
|
|
||||||
|
// Remove any non-digit characters except +
|
||||||
|
const cleanPhone = phone.replace(/[^\d+]/g, "");
|
||||||
|
|
||||||
|
// If phone already includes country code, return as is
|
||||||
|
if (cleanPhone.startsWith(countryCode)) {
|
||||||
|
return cleanPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, prepend country code
|
||||||
|
return `${countryCode}${cleanPhone}`;
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
.whats-new {
|
.whats-new {
|
||||||
.whats-new__wifi-icon {
|
.whats-new__wifi-icon {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
.header {
|
.header {
|
||||||
.header__toolbar {
|
.header__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.header__left-group {
|
.header__left-group {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px; // optional spacing between menu and dropdown
|
gap: 12px; // optional spacing between menu and dropdown
|
||||||
}
|
|
||||||
|
|
||||||
.header__right-group {
|
|
||||||
margin-left: auto; // pushes it to the far right
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header__right-group {
|
||||||
|
margin-left: auto; // pushes it to the far right
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,11 @@ export default function AccountMenu() {
|
|||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleGoToSettings = () => {
|
||||||
|
router.push("/dashboard/settings");
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
// Select relevant state from your auth slice
|
// Select relevant state from your auth slice
|
||||||
const isLoggedIn = useSelector(selectIsLoggedIn);
|
const isLoggedIn = useSelector(selectIsLoggedIn);
|
||||||
const authStatus = useSelector(selectStatus);
|
const authStatus = useSelector(selectStatus);
|
||||||
@ -42,22 +47,10 @@ export default function AccountMenu() {
|
|||||||
const isLoggingOut = authStatus === "loading";
|
const isLoggingOut = authStatus === "loading";
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
// Dispatch the logout thunk
|
// Dispatch the logout thunk - redirection will be handled by the epic
|
||||||
const resultAction = await dispatch(logout());
|
await dispatch(logout());
|
||||||
|
|
||||||
// Check if logout was successful based on the action result
|
|
||||||
if (logout.fulfilled.match(resultAction)) {
|
|
||||||
console.log("Logout successful, redirecting...");
|
|
||||||
router.push("/login"); // Redirect to your login page
|
|
||||||
} else {
|
|
||||||
// Handle logout failure (e.g., show an error message)
|
|
||||||
console.error("Logout failed:", resultAction.payload);
|
|
||||||
// You might want to display a toast or alert here
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("[isLoggedin]", isLoggedIn);
|
|
||||||
|
|
||||||
// Only show the logout button if the user is logged in
|
// Only show the logout button if the user is logged in
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return null;
|
return null;
|
||||||
@ -84,7 +77,7 @@ export default function AccountMenu() {
|
|||||||
<Typography variant="inherit">Account</Typography>
|
<Typography variant="inherit">Account</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
<MenuItem>
|
<MenuItem onClick={handleGoToSettings}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<SettingsIcon fontSize="small" />
|
<SettingsIcon fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
.sidebar-dropdown__container {
|
.sidebar-dropdown__container {
|
||||||
.page-link__container {
|
.page-link__container {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
.page-link__text {
|
.page-link__text {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { styled } from '@mui/system';
|
import { styled } from "@mui/system";
|
||||||
|
|
||||||
export const MainContent = styled('div')(({ theme }) => ({
|
export const MainContent = styled("div")(({ theme }) => ({
|
||||||
marginLeft: '240px',
|
marginLeft: "240px",
|
||||||
padding: theme.spacing(3),
|
padding: theme.spacing(3),
|
||||||
minHeight: '100vh',
|
minHeight: "100vh",
|
||||||
width: 'calc(100% - 240px)',
|
width: "calc(100% - 240px)",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const SideBar = () => {
|
|||||||
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const toggleMenu = (title: string) => {
|
const toggleMenu = (title: string) => {
|
||||||
setOpenMenus((prev) => ({ ...prev, [title]: !prev[title] }));
|
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -24,7 +24,7 @@ const SideBar = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{PAGE_LINKS.map((link) =>
|
{PAGE_LINKS.map(link =>
|
||||||
link.children ? (
|
link.children ? (
|
||||||
<div key={link.title}>
|
<div key={link.title}>
|
||||||
<button
|
<button
|
||||||
@ -43,7 +43,7 @@ const SideBar = () => {
|
|||||||
</button>
|
</button>
|
||||||
{openMenus[link.title] && (
|
{openMenus[link.title] && (
|
||||||
<div className="sidebar__submenu">
|
<div className="sidebar__submenu">
|
||||||
{link.children.map((child) => (
|
{link.children.map(child => (
|
||||||
<PageLinks
|
<PageLinks
|
||||||
key={child.path}
|
key={child.path}
|
||||||
title={child.title}
|
title={child.title}
|
||||||
|
|||||||
@ -11,12 +11,11 @@ import InsightsIcon from "@mui/icons-material/Insights";
|
|||||||
import ListAltIcon from "@mui/icons-material/ListAlt";
|
import ListAltIcon from "@mui/icons-material/ListAlt";
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
|
|
||||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import FactCheckIcon from "@mui/icons-material/FactCheck";
|
import FactCheckIcon from "@mui/icons-material/FactCheck";
|
||||||
|
|
||||||
|
|
||||||
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces";
|
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces";
|
||||||
|
|
||||||
export const PAGE_LINKS: ISidebarLink[] = [
|
export const PAGE_LINKS: ISidebarLink[] = [
|
||||||
@ -25,7 +24,8 @@ export const PAGE_LINKS: ISidebarLink[] = [
|
|||||||
{
|
{
|
||||||
title: "Transaction",
|
title: "Transaction",
|
||||||
path: "/dashboard/transactions",
|
path: "/dashboard/transactions",
|
||||||
icon: AccountBalanceWalletIcon, children: [
|
icon: AccountBalanceWalletIcon,
|
||||||
|
children: [
|
||||||
{
|
{
|
||||||
title: "Deposits",
|
title: "Deposits",
|
||||||
path: "/dashboard/transactions/deposits",
|
path: "/dashboard/transactions/deposits",
|
||||||
|
|||||||
@ -36,10 +36,13 @@ export function useTokenExpiration() {
|
|||||||
}, logoutDelay);
|
}, logoutDelay);
|
||||||
|
|
||||||
// Also set up periodic checks every 5 minutes
|
// Also set up periodic checks every 5 minutes
|
||||||
const checkInterval = setInterval(() => {
|
const checkInterval = setInterval(
|
||||||
// Re-dispatch checkAuthStatus to get updated expiration time
|
() => {
|
||||||
// This will be handled by the ReduxProvider
|
// Re-dispatch checkAuthStatus to get updated expiration time
|
||||||
}, 5 * 60 * 1000); // 5 minutes
|
// This will be handled by the ReduxProvider
|
||||||
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
); // 5 minutes
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import ThemeRegistry from "@/config/ThemeRegistry";
|
// app/layout.tsx
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import ReduxProvider from "./redux/ReduxProvider";
|
import ThemeRegistry from "@/config/ThemeRegistry";
|
||||||
|
import ReduxProvider from "./ReduxProvider"; // moved into app/
|
||||||
|
import { AuthBootstrap } from "./AuthBootstrap"; // moved into app/
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
import "../styles/globals.scss";
|
import "../styles/globals.scss";
|
||||||
import { InitializeAuth } from "./redux/InitializeAuth";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Your App",
|
title: "Your App",
|
||||||
@ -18,8 +20,10 @@ export default function RootLayout({
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
<ReduxProvider>
|
<ReduxProvider>
|
||||||
<InitializeAuth />
|
{/* Bootstraps session validation + redirect if invalid */}
|
||||||
|
<AuthBootstrap />
|
||||||
<ThemeRegistry>{children}</ThemeRegistry>
|
<ThemeRegistry>{children}</ThemeRegistry>
|
||||||
|
<Toaster position="top-right" reverseOrder={false} />
|
||||||
</ReduxProvider>
|
</ReduxProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -10,9 +10,15 @@ import {
|
|||||||
selectAuthMessage,
|
selectAuthMessage,
|
||||||
selectIsLoggedIn,
|
selectIsLoggedIn,
|
||||||
selectStatus,
|
selectStatus,
|
||||||
|
selectUser,
|
||||||
} from "../redux/auth/selectors";
|
} from "../redux/auth/selectors";
|
||||||
import { clearAuthMessage, login } from "../redux/auth/authSlice";
|
import {
|
||||||
|
clearAuthMessage,
|
||||||
|
login,
|
||||||
|
changePassword,
|
||||||
|
} from "../redux/auth/authSlice";
|
||||||
import "./page.scss";
|
import "./page.scss";
|
||||||
|
import { ChangePassword } from "../features/Auth/ChangePassword/ChangePassword";
|
||||||
|
|
||||||
export default function LoginPageClient() {
|
export default function LoginPageClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -22,8 +28,14 @@ export default function LoginPageClient() {
|
|||||||
const isLoggedIn = useSelector(selectIsLoggedIn);
|
const isLoggedIn = useSelector(selectIsLoggedIn);
|
||||||
const status = useSelector(selectStatus);
|
const status = useSelector(selectStatus);
|
||||||
const authMessage = useSelector(selectAuthMessage);
|
const authMessage = useSelector(selectAuthMessage);
|
||||||
|
const user = useSelector(selectUser);
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const [redirectMessage, setRedirectMessage] = useState<string>("");
|
const [redirectMessage, setRedirectMessage] = useState<string>("");
|
||||||
|
const [mustChangePassword, setMustChangePassword] = useState<boolean>(
|
||||||
|
reason === "change-password"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[DEBUG] [reason]:", reason === "change-password");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set message based on redirect reason
|
// Set message based on redirect reason
|
||||||
@ -33,14 +45,22 @@ export default function LoginPageClient() {
|
|||||||
setRedirectMessage("Invalid session detected. Please log in again.");
|
setRedirectMessage("Invalid session detected. Please log in again.");
|
||||||
} else if (reason === "no-token") {
|
} else if (reason === "no-token") {
|
||||||
setRedirectMessage("Please log in to access the backoffice.");
|
setRedirectMessage("Please log in to access the backoffice.");
|
||||||
|
} else if (reason === "change-password") {
|
||||||
|
setRedirectMessage(
|
||||||
|
"Please change your password to access the backoffice."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [reason]);
|
}, [reason]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn && status === "succeeded") {
|
setMustChangePassword(reason === "change-password" ? true : false);
|
||||||
|
}, [reason]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn && status === "succeeded" && !mustChangePassword) {
|
||||||
router.replace(redirectPath);
|
router.replace(redirectPath);
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, status, router, redirectPath]);
|
}, [isLoggedIn, status, mustChangePassword, router, redirectPath]);
|
||||||
|
|
||||||
const handleLogin = async (email: string, password: string) => {
|
const handleLogin = async (email: string, password: string) => {
|
||||||
const resultAction = await dispatch(login({ email, password }));
|
const resultAction = await dispatch(login({ email, password }));
|
||||||
@ -51,7 +71,31 @@ export default function LoginPageClient() {
|
|||||||
dispatch(clearAuthMessage());
|
dispatch(clearAuthMessage());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoggedIn) {
|
const handleChangePassword = async (passwordData: {
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword: string;
|
||||||
|
}) => {
|
||||||
|
if (!user?.email) {
|
||||||
|
console.error("No user email available for password change");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultAction = await dispatch(
|
||||||
|
changePassword({
|
||||||
|
email: user.email,
|
||||||
|
newPassword: passwordData.newPassword,
|
||||||
|
currentPassword: passwordData.currentPassword,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const result = changePassword.fulfilled.match(resultAction);
|
||||||
|
|
||||||
|
// if(result && resultAction.payload.success) {
|
||||||
|
// setMustChangePassword(!resultAction.payload.success);
|
||||||
|
// }
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoggedIn && !mustChangePassword) {
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-container__content">
|
<div className="page-container__content">
|
||||||
@ -66,13 +110,23 @@ export default function LoginPageClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<Modal open={true} title="Login to Payment Cashier">
|
{mustChangePassword ? (
|
||||||
<LoginModal
|
<Modal open={true} title="Change Your Temporary Password">
|
||||||
onLogin={handleLogin}
|
<ChangePassword
|
||||||
authMessage={authMessage}
|
open={true}
|
||||||
clearAuthMessage={handleClearAuthMessage}
|
onClose={() => {}}
|
||||||
/>
|
onSubmit={handleChangePassword}
|
||||||
</Modal>
|
/>
|
||||||
|
</Modal>
|
||||||
|
) : (
|
||||||
|
<Modal open={true} title="Login to Payment Cashier">
|
||||||
|
<LoginModal
|
||||||
|
onLogin={handleLogin}
|
||||||
|
authMessage={authMessage}
|
||||||
|
clearAuthMessage={handleClearAuthMessage}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
@use "sass:color";
|
||||||
|
|
||||||
// Variables for consistent styling
|
// Variables for consistent styling
|
||||||
$primary-color: #2563eb; // Blue-600 equivalent
|
$primary-color: #2563eb; // Blue-600 equivalent
|
||||||
$primary-hover-color: #1d4ed8; // Blue-700 equivalent
|
$primary-hover-color: #1d4ed8; // Blue-700 equivalent
|
||||||
@ -26,7 +28,8 @@ $bg-color-white: #ffffff;
|
|||||||
max-width: 56rem; // max-w-4xl
|
max-width: 56rem; // max-w-4xl
|
||||||
background-color: $bg-color-white;
|
background-color: $bg-color-white;
|
||||||
border-radius: 0.75rem; // rounded-xl
|
border-radius: 0.75rem; // rounded-xl
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
|
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
|
||||||
padding: 2rem; // p-8
|
padding: 2rem; // p-8
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -59,6 +62,9 @@ $bg-color-white: #ffffff;
|
|||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md
|
||||||
transition: background-color 0.3s ease-in-out;
|
transition: background-color 0.3s ease-in-out;
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: darken($error-color, 5%); // red-700 equivalent
|
background-color: color.adjust(
|
||||||
|
$error-color,
|
||||||
|
$lightness: -5%
|
||||||
|
); // red-700 equivalent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,9 +24,12 @@ export default function ReduxProvider({
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
// Set up periodic token validation every 5 minutes
|
// Set up periodic token validation every 5 minutes
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(
|
||||||
store.dispatch(checkAuthStatus());
|
() => {
|
||||||
}, 5 * 60 * 1000); // 5 minutes
|
store.dispatch(checkAuthStatus());
|
||||||
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
); // 5 minutes
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
|
|||||||
@ -1,20 +1,21 @@
|
|||||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
import { ThunkSuccess, ThunkError, IUserResponse } from "../types";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
interface TokenInfo {
|
||||||
|
expiresAt: string;
|
||||||
|
timeUntilExpiration: number;
|
||||||
|
expiresInHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Define the initial state for the authentication slice
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
authMessage: string;
|
authMessage: string;
|
||||||
status: "idle" | "loading" | "succeeded" | "failed";
|
status: "idle" | "loading" | "succeeded" | "failed";
|
||||||
error: string | null;
|
error: string | null;
|
||||||
user: {
|
user: IUserResponse | null;
|
||||||
email: string;
|
tokenInfo: TokenInfo | null;
|
||||||
role: string;
|
mustChangePassword: boolean;
|
||||||
} | null;
|
|
||||||
tokenInfo: {
|
|
||||||
expiresAt: string;
|
|
||||||
timeUntilExpiration: number;
|
|
||||||
expiresInHours: number;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
@ -24,318 +25,295 @@ const initialState: AuthState = {
|
|||||||
error: null,
|
error: null,
|
||||||
user: null,
|
user: null,
|
||||||
tokenInfo: null,
|
tokenInfo: null,
|
||||||
|
mustChangePassword: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async Thunk for Login
|
// ---------------- Login ----------------
|
||||||
// This handles the API call to your Next.js login Route Handler
|
export const login = createAsyncThunk<
|
||||||
export const login = createAsyncThunk(
|
ThunkSuccess<{
|
||||||
|
user: IUserResponse | null;
|
||||||
|
tokenInfo: TokenInfo | null;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}>,
|
||||||
|
{ email: string; password: string },
|
||||||
|
{ rejectValue: ThunkError }
|
||||||
|
>(
|
||||||
"auth/login",
|
"auth/login",
|
||||||
async (
|
async (
|
||||||
{ email, password }: { email: string; password: string },
|
{ email, password }: { email: string; password: string },
|
||||||
{ rejectWithValue }
|
{ rejectWithValue }
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/login", {
|
const res = await fetch("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
if (!response.ok) {
|
toast.error(data.message || "Login failed");
|
||||||
// If the server responded with an error status (e.g., 401, 400, 500)
|
return rejectWithValue((data.message as string) || "Login failed");
|
||||||
return rejectWithValue(data.message || "Login failed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// On successful login, the backend sets the HTTP-only cookie.
|
// After login, validate session right away
|
||||||
// We'll set a client-side flag (like localStorage) for immediate UI updates,
|
const validateRes = await fetch("/api/auth/validate", { method: "POST" });
|
||||||
// though the primary source of truth for auth is the HTTP-only cookie.
|
const validateData = await validateRes.json();
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
// Ensure localStorage access is client-side
|
|
||||||
localStorage.setItem("userToken", "mock-authenticated"); // For client-side state sync
|
|
||||||
}
|
|
||||||
|
|
||||||
// After successful login, check auth status to get token information
|
toast.success(data.message || "Login successful");
|
||||||
// This ensures we have the token expiration details immediately
|
|
||||||
const authStatusResponse = await fetch("/api/auth/status");
|
|
||||||
if (authStatusResponse.ok) {
|
|
||||||
const authData = await authStatusResponse.json();
|
|
||||||
return {
|
|
||||||
message: data.message || "Login successful",
|
|
||||||
user: authData.user,
|
|
||||||
tokenInfo: authData.tokenInfo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.message || "Login successful";
|
return {
|
||||||
} catch (error: unknown) {
|
message: data.message || "Login successful",
|
||||||
// Handle network errors or other unexpected issues
|
user: data.user || null,
|
||||||
const errorMessage =
|
tokenInfo: validateData.tokenInfo || null,
|
||||||
error instanceof Error ? error.message : "Network error during login";
|
mustChangePassword: data.must_change_password || false,
|
||||||
return rejectWithValue(errorMessage);
|
} as ThunkSuccess<{
|
||||||
|
user: IUserResponse | null;
|
||||||
|
tokenInfo: TokenInfo | null;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}>;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return rejectWithValue(
|
||||||
|
(err as Error).message || "Network error during login"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Async Thunk for Logout
|
// ---------------- Logout ----------------
|
||||||
// This handles the API call to your Next.js logout Route Handler
|
export const logout = createAsyncThunk<
|
||||||
export const logout = createAsyncThunk(
|
ThunkSuccess,
|
||||||
"auth/logout",
|
void,
|
||||||
async (_, { rejectWithValue }) => {
|
{ rejectValue: ThunkError }
|
||||||
|
>("auth/logout", async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/logout", { method: "DELETE" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok)
|
||||||
|
return rejectWithValue((data.message as string) || "Logout failed");
|
||||||
|
|
||||||
|
// Persisted state will be cleared by reducers below; avoid direct persistor usage here.
|
||||||
|
|
||||||
|
return { message: data.message || "Logged out successfully" };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return rejectWithValue(
|
||||||
|
(err as Error).message || "Network error during logout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------- Auto Logout ----------------
|
||||||
|
export const autoLogout = createAsyncThunk<
|
||||||
|
ThunkSuccess,
|
||||||
|
string,
|
||||||
|
{ rejectValue: ThunkError }
|
||||||
|
>("auth/autoLogout", async (reason: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "DELETE" });
|
||||||
|
return { message: `Auto-logout: ${reason}` };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return rejectWithValue((err as Error).message || "Auto-logout failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------- Change Password ----------------
|
||||||
|
export const changePassword = createAsyncThunk<
|
||||||
|
ThunkSuccess<{ success: boolean }>,
|
||||||
|
{ email: string; newPassword: string; currentPassword?: string },
|
||||||
|
{ rejectValue: ThunkError }
|
||||||
|
>(
|
||||||
|
"auth/changePassword",
|
||||||
|
async ({ email, newPassword, currentPassword }, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/logout", {
|
const res = await fetch("/api/auth/change-password", {
|
||||||
method: "DELETE",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, newPassword, currentPassword }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await res.json();
|
||||||
|
if (!res.ok)
|
||||||
|
return rejectWithValue(
|
||||||
|
(data.message as string) || "Password change failed"
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
return {
|
||||||
// If the server responded with an error status
|
success: data.success,
|
||||||
return rejectWithValue(data.message || "Logout failed");
|
message: data.message || "Password changed successfully",
|
||||||
}
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
if (typeof window !== "undefined") {
|
return rejectWithValue(
|
||||||
// Ensure localStorage access is client-side
|
(err as Error).message || "Network error during password change"
|
||||||
localStorage.removeItem("userToken"); // Clear client-side flag
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// After successful logout, check auth status to ensure proper cleanup
|
|
||||||
const authStatusResponse = await fetch("/api/auth/status");
|
|
||||||
if (authStatusResponse.ok) {
|
|
||||||
const authData = await authStatusResponse.json();
|
|
||||||
return {
|
|
||||||
message: data.message || "Logged out successfully",
|
|
||||||
isAuthenticated: authData.isAuthenticated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.message || "Logged out successfully";
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Handle network errors
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "Network error during logout";
|
|
||||||
return rejectWithValue(errorMessage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Async Thunk for automatic logout (when token expires)
|
// ---------------- Unified Validate Auth ----------------
|
||||||
export const autoLogout = createAsyncThunk(
|
export const validateAuth = createAsyncThunk<
|
||||||
"auth/autoLogout",
|
{ tokenInfo: TokenInfo | null },
|
||||||
async (reason: string, { rejectWithValue }) => {
|
void,
|
||||||
try {
|
{ rejectValue: ThunkError }
|
||||||
// Clear the cookie by calling logout endpoint
|
>("auth/validate", async (_, { rejectWithValue }) => {
|
||||||
await fetch("/api/auth/logout", {
|
try {
|
||||||
method: "DELETE",
|
const res = await fetch("/api/auth/validate", { method: "POST" });
|
||||||
});
|
const data = await res.json();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (!res.ok || !data.valid) {
|
||||||
// Clear client-side storage
|
return rejectWithValue("Session invalid or expired");
|
||||||
localStorage.removeItem("userToken");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: `Auto-logout: ${reason}` };
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "Auto-logout failed";
|
|
||||||
return rejectWithValue(errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenInfo: data.tokenInfo || null,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return rejectWithValue(
|
||||||
|
(err as Error).message || "Network error during validation"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
// Async Thunk for manual refresh of authentication status
|
// TODO - Creaye a new thunk to update the user stuff
|
||||||
export const refreshAuthStatus = createAsyncThunk(
|
|
||||||
"auth/refreshStatus",
|
|
||||||
async (_, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/status");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// ---------------- Update User Details ----------------
|
||||||
return rejectWithValue(data.message || "Authentication refresh failed");
|
export const updateUserDetails = createAsyncThunk<
|
||||||
}
|
ThunkSuccess<{ user: IUserResponse }>,
|
||||||
|
{ id: string; updates: Partial<IUserResponse> },
|
||||||
|
{ rejectValue: ThunkError }
|
||||||
|
>("auth/updateUserDetails", async ({ id, updates }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dashboard/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
|
||||||
return data;
|
const data = await res.json();
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage =
|
if (!res.ok) {
|
||||||
error instanceof Error
|
return rejectWithValue(data.message || "Failed to update user details");
|
||||||
? error.message
|
|
||||||
: "Network error during auth refresh";
|
|
||||||
return rejectWithValue(errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: data.message || "User details updated successfully",
|
||||||
|
user: data.user,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return rejectWithValue(
|
||||||
|
(err as Error).message || "Network error during user update"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
// Async Thunk for checking authentication status
|
// ---------------- Slice ----------------
|
||||||
export const checkAuthStatus = createAsyncThunk(
|
|
||||||
"auth/checkStatus",
|
|
||||||
async (_, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/status");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return rejectWithValue(data.message || "Authentication check failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Network error during auth check";
|
|
||||||
return rejectWithValue(errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create the authentication slice
|
|
||||||
const authSlice = createSlice({
|
const authSlice = createSlice({
|
||||||
name: "auth",
|
name: "auth",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
// Reducer to set an authentication message (e.g., from UI actions)
|
|
||||||
setAuthMessage: (state, action: PayloadAction<string>) => {
|
setAuthMessage: (state, action: PayloadAction<string>) => {
|
||||||
state.authMessage = action.payload;
|
state.authMessage = action.payload;
|
||||||
},
|
},
|
||||||
// Reducer to clear the authentication message
|
clearAuthMessage: state => {
|
||||||
clearAuthMessage: (state) => {
|
|
||||||
state.authMessage = "";
|
state.authMessage = "";
|
||||||
},
|
},
|
||||||
// Reducer to initialize login status from client-side storage (e.g., on app load)
|
|
||||||
// This is useful for cases where middleware might not redirect immediately,
|
|
||||||
// or for client-side rendering of protected content based on initial state.
|
|
||||||
initializeAuth: (state) => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
// Ensure this runs only on the client
|
|
||||||
const userToken = localStorage.getItem("userToken");
|
|
||||||
state.isLoggedIn = userToken === "mock-authenticated";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
// Login Thunk Reducers
|
// Login
|
||||||
.addCase(login.pending, (state) => {
|
.addCase(login.pending, state => {
|
||||||
state.status = "loading";
|
state.status = "loading";
|
||||||
state.error = null;
|
|
||||||
state.authMessage = "Attempting login...";
|
state.authMessage = "Attempting login...";
|
||||||
})
|
})
|
||||||
.addCase(login.fulfilled, (state, action) => {
|
.addCase(login.fulfilled, (state, action) => {
|
||||||
state.status = "succeeded";
|
state.status = "succeeded";
|
||||||
state.isLoggedIn = true;
|
state.isLoggedIn = true;
|
||||||
// Handle both old string payload and new object payload
|
state.authMessage = action.payload.message;
|
||||||
if (typeof action.payload === "string") {
|
state.user = action.payload.user;
|
||||||
state.authMessage = action.payload;
|
state.tokenInfo = action.payload.tokenInfo;
|
||||||
} else {
|
state.mustChangePassword = action.payload.mustChangePassword;
|
||||||
state.authMessage = action.payload.message;
|
|
||||||
state.user = action.payload.user || null;
|
|
||||||
state.tokenInfo = action.payload.tokenInfo || null;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.addCase(login.rejected, (state, action) => {
|
.addCase(login.rejected, (state, action) => {
|
||||||
state.status = "failed";
|
state.status = "failed";
|
||||||
state.isLoggedIn = false;
|
state.isLoggedIn = false;
|
||||||
state.error = action.payload as string;
|
state.error = action.payload as string;
|
||||||
state.authMessage = action.payload as string; // Display error message
|
state.authMessage = action.payload as string;
|
||||||
})
|
|
||||||
// Logout Thunk Reducers
|
|
||||||
.addCase(logout.pending, (state) => {
|
|
||||||
state.status = "loading";
|
|
||||||
state.error = null;
|
|
||||||
state.authMessage = "Logging out...";
|
|
||||||
})
|
})
|
||||||
|
// Logout
|
||||||
.addCase(logout.fulfilled, (state, action) => {
|
.addCase(logout.fulfilled, (state, action) => {
|
||||||
state.status = "succeeded";
|
state.status = "succeeded";
|
||||||
state.isLoggedIn = false;
|
state.isLoggedIn = false;
|
||||||
// Handle both old string payload and new object payload
|
state.authMessage = action.payload.message;
|
||||||
if (typeof action.payload === "string") {
|
|
||||||
state.authMessage = action.payload;
|
|
||||||
} else {
|
|
||||||
state.authMessage = action.payload.message;
|
|
||||||
// Ensure we're properly logged out
|
|
||||||
if (action.payload.isAuthenticated === false) {
|
|
||||||
state.user = null;
|
|
||||||
state.tokenInfo = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Always clear user data on logout
|
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.tokenInfo = null;
|
state.tokenInfo = null;
|
||||||
|
state.mustChangePassword = false;
|
||||||
})
|
})
|
||||||
.addCase(logout.rejected, (state, action) => {
|
.addCase(logout.rejected, (state, action) => {
|
||||||
state.status = "failed";
|
state.status = "failed";
|
||||||
state.isLoggedIn = true; // Stay logged in if logout failed
|
|
||||||
state.error = action.payload as string;
|
state.error = action.payload as string;
|
||||||
state.authMessage = action.payload as string; // Display error message
|
state.authMessage = action.payload as string;
|
||||||
})
|
|
||||||
// Check Auth Status Thunk Reducers
|
|
||||||
.addCase(checkAuthStatus.pending, (state) => {
|
|
||||||
state.status = "loading";
|
|
||||||
state.error = null;
|
|
||||||
})
|
|
||||||
.addCase(checkAuthStatus.fulfilled, (state, action) => {
|
|
||||||
state.status = "succeeded";
|
|
||||||
state.isLoggedIn = action.payload.isAuthenticated;
|
|
||||||
state.user = action.payload.user || null;
|
|
||||||
state.tokenInfo = action.payload.tokenInfo || null;
|
|
||||||
state.error = null;
|
|
||||||
})
|
|
||||||
.addCase(checkAuthStatus.rejected, (state, action) => {
|
|
||||||
state.status = "failed";
|
|
||||||
state.isLoggedIn = false;
|
|
||||||
state.user = null;
|
|
||||||
state.tokenInfo = null;
|
|
||||||
state.error = action.payload as string;
|
|
||||||
})
|
|
||||||
// Auto Logout Thunk Reducers
|
|
||||||
.addCase(autoLogout.pending, (state) => {
|
|
||||||
state.status = "loading";
|
|
||||||
state.error = null;
|
|
||||||
state.authMessage = "Session expired, logging out...";
|
|
||||||
})
|
})
|
||||||
|
// Auto Logout
|
||||||
.addCase(autoLogout.fulfilled, (state, action) => {
|
.addCase(autoLogout.fulfilled, (state, action) => {
|
||||||
state.status = "succeeded";
|
state.status = "succeeded";
|
||||||
state.isLoggedIn = false;
|
state.isLoggedIn = false;
|
||||||
state.authMessage = action.payload.message;
|
state.authMessage = action.payload.message;
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.tokenInfo = null;
|
state.tokenInfo = null;
|
||||||
state.error = null;
|
state.mustChangePassword = false;
|
||||||
})
|
})
|
||||||
.addCase(autoLogout.rejected, (state, action) => {
|
.addCase(autoLogout.rejected, (state, action) => {
|
||||||
state.status = "failed";
|
state.status = "failed";
|
||||||
state.isLoggedIn = false;
|
|
||||||
state.user = null;
|
|
||||||
state.tokenInfo = null;
|
|
||||||
state.error = action.payload as string;
|
state.error = action.payload as string;
|
||||||
state.authMessage = "Auto-logout failed";
|
|
||||||
})
|
})
|
||||||
// Refresh Auth Status Thunk Reducers
|
// Change Password
|
||||||
.addCase(refreshAuthStatus.pending, (state) => {
|
.addCase(changePassword.pending, state => {
|
||||||
state.status = "loading";
|
state.status = "loading";
|
||||||
state.error = null;
|
state.authMessage = "Changing password...";
|
||||||
})
|
})
|
||||||
.addCase(refreshAuthStatus.fulfilled, (state, action) => {
|
.addCase(changePassword.fulfilled, (state, action) => {
|
||||||
state.status = "succeeded";
|
state.status = "succeeded";
|
||||||
state.isLoggedIn = action.payload.isAuthenticated;
|
state.authMessage = action.payload.message;
|
||||||
state.user = action.payload.user || null;
|
state.mustChangePassword = action.payload.success;
|
||||||
state.tokenInfo = action.payload.tokenInfo || null;
|
|
||||||
state.error = null;
|
|
||||||
})
|
})
|
||||||
.addCase(refreshAuthStatus.rejected, (state, action) => {
|
.addCase(changePassword.rejected, (state, action) => {
|
||||||
|
state.status = "failed";
|
||||||
|
state.error = action.payload as string;
|
||||||
|
state.authMessage = action.payload as string;
|
||||||
|
})
|
||||||
|
// Validate Auth
|
||||||
|
.addCase(validateAuth.pending, state => {
|
||||||
|
state.status = "loading";
|
||||||
|
})
|
||||||
|
.addCase(validateAuth.fulfilled, (state, action) => {
|
||||||
|
state.status = "succeeded";
|
||||||
|
state.isLoggedIn = true;
|
||||||
|
state.tokenInfo = action.payload.tokenInfo;
|
||||||
|
})
|
||||||
|
.addCase(validateAuth.rejected, (state, action) => {
|
||||||
state.status = "failed";
|
state.status = "failed";
|
||||||
state.isLoggedIn = false;
|
state.isLoggedIn = false;
|
||||||
state.user = null;
|
|
||||||
state.tokenInfo = null;
|
state.tokenInfo = null;
|
||||||
state.error = action.payload as string;
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// Update User Details
|
||||||
|
.addCase(updateUserDetails.pending, state => {
|
||||||
|
state.status = "loading";
|
||||||
|
state.authMessage = "Updating user details...";
|
||||||
|
})
|
||||||
|
.addCase(updateUserDetails.fulfilled, (state, action) => {
|
||||||
|
state.status = "succeeded";
|
||||||
|
state.user = action.payload.user;
|
||||||
|
state.authMessage = action.payload.message;
|
||||||
|
})
|
||||||
|
.addCase(updateUserDetails.rejected, (state, action) => {
|
||||||
|
state.status = "failed";
|
||||||
|
state.error = action.payload as string;
|
||||||
|
state.authMessage = action.payload as string;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setAuthMessage, clearAuthMessage, initializeAuth } =
|
export const { setAuthMessage, clearAuthMessage } = authSlice.actions;
|
||||||
authSlice.actions;
|
|
||||||
|
|
||||||
export default authSlice.reducer;
|
export default authSlice.reducer;
|
||||||
|
|||||||
17
app/redux/auth/epic.ts
Normal file
17
app/redux/auth/epic.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Epic } from "redux-observable";
|
||||||
|
import { logout } from "./authSlice";
|
||||||
|
import { filter, tap, ignoreElements } from "rxjs/operators";
|
||||||
|
|
||||||
|
export const logoutRedirectEpic: Epic = action$ =>
|
||||||
|
action$.pipe(
|
||||||
|
filter(logout.fulfilled.match),
|
||||||
|
tap(() => {
|
||||||
|
// Use window.location for redirection in epics since we can't use hooks
|
||||||
|
window.location.href = "/login";
|
||||||
|
}),
|
||||||
|
ignoreElements()
|
||||||
|
);
|
||||||
|
|
||||||
|
const authEpics = [logoutRedirectEpic];
|
||||||
|
|
||||||
|
export default authEpics;
|
||||||
@ -6,6 +6,8 @@ export const selectError = (state: RootState) => state.auth?.error;
|
|||||||
export const selectAuthMessage = (state: RootState) => state.auth?.authMessage;
|
export const selectAuthMessage = (state: RootState) => state.auth?.authMessage;
|
||||||
export const selectUser = (state: RootState) => state.auth?.user;
|
export const selectUser = (state: RootState) => state.auth?.user;
|
||||||
export const selectTokenInfo = (state: RootState) => state.auth?.tokenInfo;
|
export const selectTokenInfo = (state: RootState) => state.auth?.tokenInfo;
|
||||||
|
export const selectMustChangePassword = (state: RootState) =>
|
||||||
|
state.auth?.mustChangePassword;
|
||||||
export const selectTimeUntilExpiration = (state: RootState) =>
|
export const selectTimeUntilExpiration = (state: RootState) =>
|
||||||
state.auth?.tokenInfo?.timeUntilExpiration || 0;
|
state.auth?.tokenInfo?.timeUntilExpiration || 0;
|
||||||
export const selectExpiresInHours = (state: RootState) =>
|
export const selectExpiresInHours = (state: RootState) =>
|
||||||
|
|||||||
@ -1,17 +1,70 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import {
|
||||||
|
configureStore,
|
||||||
|
combineReducers,
|
||||||
|
type Reducer,
|
||||||
|
} from "@reduxjs/toolkit";
|
||||||
|
import storage from "redux-persist/lib/storage"; // defaults to localStorage for web
|
||||||
|
import { createTransform, persistReducer, persistStore } from "redux-persist";
|
||||||
|
import { createEpicMiddleware, combineEpics } from "redux-observable";
|
||||||
import advancedSearchReducer from "./advanedSearch/advancedSearchSlice";
|
import advancedSearchReducer from "./advanedSearch/advancedSearchSlice";
|
||||||
import authReducer from "./auth/authSlice";
|
import authReducer from "./auth/authSlice";
|
||||||
|
import userEpics from "./user/epic";
|
||||||
|
import authEpics from "./auth/epic";
|
||||||
|
|
||||||
export const makeStore = () => {
|
type PersistedAuth = { user: unknown | null };
|
||||||
return configureStore({
|
|
||||||
reducer: {
|
const persistConfig = {
|
||||||
advancedSearch: advancedSearchReducer,
|
key: "root",
|
||||||
auth: authReducer,
|
storage,
|
||||||
},
|
whitelist: ["auth"], // only persist auth slice
|
||||||
// Enable Redux DevTools
|
transforms: [
|
||||||
devTools: process.env.NODE_ENV !== "production",
|
// Persist only the `user` subkey from the auth slice
|
||||||
});
|
createTransform(
|
||||||
|
(inboundState: unknown): PersistedAuth => {
|
||||||
|
const s = inboundState as { user?: unknown } | null | undefined;
|
||||||
|
return { user: s?.user ?? null };
|
||||||
|
},
|
||||||
|
(outboundState: unknown): PersistedAuth => {
|
||||||
|
const s = outboundState as { user?: unknown } | null | undefined;
|
||||||
|
return { user: s?.user ?? null };
|
||||||
|
},
|
||||||
|
{ whitelist: ["auth"] }
|
||||||
|
),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the store instance
|
const rootReducer = combineReducers({
|
||||||
|
advancedSearch: advancedSearchReducer,
|
||||||
|
auth: authReducer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootEpic = combineEpics(...userEpics, ...authEpics);
|
||||||
|
|
||||||
|
const persistedReducer = persistReducer(persistConfig, rootReducer as Reducer);
|
||||||
|
|
||||||
|
// Create epic middleware
|
||||||
|
const epicMiddleware = createEpicMiddleware();
|
||||||
|
|
||||||
|
export const makeStore = () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: persistedReducer,
|
||||||
|
devTools: process.env.NODE_ENV !== "production",
|
||||||
|
middleware: getDefaultMiddleware =>
|
||||||
|
getDefaultMiddleware({
|
||||||
|
serializableCheck: false, // redux-persist uses non-serializable values
|
||||||
|
}).concat(epicMiddleware),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run the epic
|
||||||
|
epicMiddleware.run(rootEpic);
|
||||||
|
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create store + persistor
|
||||||
export const store = makeStore();
|
export const store = makeStore();
|
||||||
|
export const persistor = persistStore(store);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|||||||
@ -3,3 +3,42 @@ import { store } from "./store";
|
|||||||
export type RootState = ReturnType<typeof store.getState>;
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
export type AppDispatch = typeof store.dispatch;
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|
||||||
|
// Generic helper types for Redux thunks that return a user-facing message
|
||||||
|
export type ThunkSuccess<T extends object = object> = { message: string } & T;
|
||||||
|
export type ThunkError = string;
|
||||||
|
|
||||||
|
export interface IUserResponse {
|
||||||
|
token: string;
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
merchantId?: number | null;
|
||||||
|
username?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
jobTitle?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
authorities?: string[];
|
||||||
|
allowedMerchantIds?: number[];
|
||||||
|
created?: string;
|
||||||
|
disabledBy?: string | null;
|
||||||
|
disabledDate?: string | null;
|
||||||
|
disabledReason?: string | null;
|
||||||
|
incidentNotes?: boolean;
|
||||||
|
lastLogin?: string | null;
|
||||||
|
lastMandatoryUpdated?: string | null;
|
||||||
|
marketingNewsletter?: boolean;
|
||||||
|
releaseNotes?: boolean;
|
||||||
|
requiredActions?: string[];
|
||||||
|
twoFactorCondition?: string | null;
|
||||||
|
twoFactorCredentials?: string[];
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILoginResponse {
|
||||||
|
user: IUserResponse;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
must_change_password: boolean;
|
||||||
|
}
|
||||||
|
|||||||
38
app/redux/user/epic.ts
Normal file
38
app/redux/user/epic.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Epic } from "redux-observable";
|
||||||
|
import { RootState } from "../types";
|
||||||
|
import { changePassword, logout } from "../auth/authSlice";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { filter, switchMap } from "rxjs/operators";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
export const changePasswordEpic: Epic<any, any, RootState> = action$ =>
|
||||||
|
action$.pipe(
|
||||||
|
// Listen for any action related to changePassword (pending, fulfilled, rejected)
|
||||||
|
filter(action => action.type.startsWith(changePassword.typePrefix)),
|
||||||
|
switchMap(action => {
|
||||||
|
if (changePassword.fulfilled.match(action)) {
|
||||||
|
toast.success(
|
||||||
|
action.payload?.message || "Password changed successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Logout the user after successful password change
|
||||||
|
return of(logout());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changePassword.rejected.match(action)) {
|
||||||
|
const errorMessage =
|
||||||
|
(action.payload as string) ||
|
||||||
|
action.error?.message ||
|
||||||
|
"Password change failed";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
return of({ type: "CHANGE_PASSWORD_ERROR_HANDLED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's pending or something else, just ignore it.
|
||||||
|
return of({ type: "CHANGE_PASSWORD_NOOP" });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const userEpics = [changePasswordEpic];
|
||||||
|
|
||||||
|
export default userEpics;
|
||||||
@ -1,13 +1,9 @@
|
|||||||
export async function getApproves({
|
export async function getApproves({ query }: { query: string }) {
|
||||||
query,
|
|
||||||
}: {
|
|
||||||
query: string;
|
|
||||||
}) {
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`http://localhost:3000/api/dashboard/approve?${query}`,
|
`http://localhost:4000/api/dashboard/approve?${query}`,
|
||||||
{
|
{
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
export async function getAudits({
|
export async function getAudits({ query }: { query: string }) {
|
||||||
query,
|
|
||||||
}: {
|
|
||||||
query: string;
|
|
||||||
}) {
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`http://localhost:3000/api/dashboard/audits?${query}`,
|
`http://localhost:4000/api/dashboard/audits?${query}`,
|
||||||
{
|
{
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@ -5,12 +5,11 @@ export async function getTransactions({
|
|||||||
transactionType: string;
|
transactionType: string;
|
||||||
query: string;
|
query: string;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`http://localhost:3000/api/dashboard/transactions/${transactionType}?${query}`,
|
`http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`,
|
||||||
{
|
{
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@ -1,17 +1,12 @@
|
|||||||
export async function getTransactionsHistory({
|
export async function getTransactionsHistory({ query }: { query: string }) {
|
||||||
query,
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
|
||||||
}: {
|
? `${process.env.NEXT_PUBLIC_BASE_URL}`
|
||||||
query: string;
|
: "http://localhost:4000";
|
||||||
}) {
|
|
||||||
const baseUrl =
|
|
||||||
process.env.NEXT_PUBLIC_BASE_URL
|
|
||||||
? `${process.env.NEXT_PUBLIC_BASE_URL}`
|
|
||||||
: "http://localhost:3000";
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${baseUrl}/api/dashboard/transactionsHistory?${query}`,
|
`${baseUrl}/api/dashboard/transactionsHistory?${query}`,
|
||||||
{
|
{
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { jwtVerify } from "jose";
|
import { jwtVerify } from "jose";
|
||||||
|
|
||||||
// Secret key for JWT verification (must match the one used for signing)
|
// Secret key for JWT verification (must match the one used for signing)
|
||||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
|
const rawSecret = (process.env.JWT_SECRET ?? "").trim().replace(/^"|"$/g, "");
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(rawSecret);
|
||||||
|
|
||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
email: string;
|
email: string;
|
||||||
@ -15,7 +16,10 @@ export interface JWTPayload {
|
|||||||
*/
|
*/
|
||||||
export async function validateToken(token: string): Promise<JWTPayload | null> {
|
export async function validateToken(token: string): Promise<JWTPayload | null> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
const raw = token.startsWith("Bearer ") ? token.slice(7) : token;
|
||||||
|
const { payload } = await jwtVerify(raw, JWT_SECRET, {
|
||||||
|
algorithms: ["HS256"],
|
||||||
|
});
|
||||||
return payload as unknown as JWTPayload;
|
return payload as unknown as JWTPayload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Token validation error:", error);
|
console.error("Token validation error:", error);
|
||||||
@ -45,3 +49,19 @@ export function getTimeUntilExpiration(payload: JWTPayload): number {
|
|||||||
const currentTime = Math.floor(Date.now() / 1000);
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
return Math.max(0, payload.exp - currentTime);
|
return Math.max(0, payload.exp - currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validatePassword(password: string) {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (password.length < 8) errors.push("At least 8 characters");
|
||||||
|
if (!/[A-Z]/.test(password)) errors.push("One uppercase letter");
|
||||||
|
if (!/[a-z]/.test(password)) errors.push("One lowercase letter");
|
||||||
|
if (!/[0-9]/.test(password)) errors.push("One number");
|
||||||
|
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password))
|
||||||
|
errors.push("One special character");
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -8,12 +8,14 @@ export const exportData = <TRow, TColumn extends GridColDef>(
|
|||||||
columns: TColumn[],
|
columns: TColumn[],
|
||||||
fileType: FileType = "csv",
|
fileType: FileType = "csv",
|
||||||
onlyCurrentTable = false,
|
onlyCurrentTable = false,
|
||||||
setOpen: (open: boolean) => void,
|
setOpen: (open: boolean) => void
|
||||||
) => {
|
) => {
|
||||||
const exportRows = onlyCurrentTable ? rows.slice(0, 5) : rows;
|
const exportRows = onlyCurrentTable ? rows.slice(0, 5) : rows;
|
||||||
const exportData = [
|
const exportData = [
|
||||||
columns.map((col) => col.headerName),
|
columns.map(col => col.headerName),
|
||||||
...exportRows.map((row) => columns.map((col) => (row as Record<string, unknown>)[col.field] ?? "")),
|
...exportRows.map(row =>
|
||||||
|
columns.map(col => (row as Record<string, unknown>)[col.field] ?? "")
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const worksheet = XLSX.utils.aoa_to_sheet(exportData);
|
const worksheet = XLSX.utils.aoa_to_sheet(exportData);
|
||||||
|
|||||||
@ -2,12 +2,12 @@ export const formatToDateTimeString = (dateString: string): string => {
|
|||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // months are 0-indexed
|
const month = String(date.getMonth() + 1).padStart(2, "0"); // months are 0-indexed
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,30 +1,81 @@
|
|||||||
// middleware.ts
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { jwtVerify } from "jose";
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
const COOKIE_NAME = "auth_token";
|
||||||
const token = request.cookies.get("auth_token")?.value; // Get token from cookie
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||||
|
|
||||||
// Define protected paths
|
function isExpired(exp?: number) {
|
||||||
const protectedPaths = ["/dashboard", "/settings", "/admin"];
|
return exp ? exp * 1000 <= Date.now() : false;
|
||||||
const isProtected = protectedPaths.some((path) =>
|
}
|
||||||
request.nextUrl.pathname.startsWith(path)
|
|
||||||
);
|
|
||||||
|
|
||||||
// If accessing a protected path and no token
|
async function validateToken(token: string) {
|
||||||
if (isProtected && !token) {
|
const raw = token.startsWith("Bearer ") ? token.slice(7) : token;
|
||||||
// Redirect to login page
|
|
||||||
const loginUrl = new URL("/login", request.url);
|
try {
|
||||||
// Optional: Add a redirect query param to return to original page after login
|
const { payload } = await jwtVerify(raw, JWT_SECRET, {
|
||||||
loginUrl.searchParams.set("redirect", request.nextUrl.pathname);
|
algorithms: ["HS256"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload as {
|
||||||
|
exp?: number;
|
||||||
|
MustChangePassword?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Token validation error:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const token = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
const currentPath = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
// 1️⃣ No token
|
||||||
|
if (!token) {
|
||||||
|
loginUrl.searchParams.set("reason", "no-token");
|
||||||
|
loginUrl.searchParams.set("redirect", currentPath);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow the request to proceed if not protected or token exists
|
// 2️⃣ Validate + decode
|
||||||
|
const payload = await validateToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
const res = NextResponse.redirect(loginUrl);
|
||||||
|
res.cookies.delete(COOKIE_NAME);
|
||||||
|
loginUrl.searchParams.set("reason", "invalid-token");
|
||||||
|
loginUrl.searchParams.set("redirect", currentPath);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ Expiry check
|
||||||
|
if (isExpired(payload.exp)) {
|
||||||
|
const res = NextResponse.redirect(loginUrl);
|
||||||
|
res.cookies.delete(COOKIE_NAME);
|
||||||
|
loginUrl.searchParams.set("reason", "expired-token");
|
||||||
|
loginUrl.searchParams.set("redirect", currentPath);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4️⃣ Must change password check
|
||||||
|
if (payload.MustChangePassword) {
|
||||||
|
loginUrl.searchParams.set("reason", "change-password");
|
||||||
|
loginUrl.searchParams.set("redirect", currentPath);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ All good
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure matcher to run middleware on specific paths
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*"], // Apply to dashboard and its sub-paths
|
matcher: [
|
||||||
|
"/dashboard/:path*",
|
||||||
|
"/settings/:path*",
|
||||||
|
"/admin/:path*",
|
||||||
|
"/change-password/:path*",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -86,19 +86,19 @@ export const handlers = [
|
|||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.user.toString() === userId
|
tx => tx.user.toString() === userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state) {
|
if (state) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.state.toLowerCase() === state.toLowerCase()
|
tx => tx.state.toLowerCase() === state.toLowerCase()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusCode) {
|
if (statusCode) {
|
||||||
filteredTransactions = filteredTransactions.filter(
|
filteredTransactions = filteredTransactions.filter(
|
||||||
(tx) => tx.pspStatusCode.toString() === statusCode
|
tx => tx.pspStatusCode.toString() === statusCode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// mocks/server.ts
|
// mocks/server.ts
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from "msw/node";
|
||||||
import { handlers } from './handlers';
|
import { handlers } from "./handlers";
|
||||||
|
|
||||||
export const server = setupServer(...handlers);
|
export const server = setupServer(...handlers);
|
||||||
|
|||||||
@ -2,7 +2,11 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
webpack: (config) => {
|
sassOptions: {
|
||||||
|
quietDeps: true,
|
||||||
|
silenceDeprecations: ["legacy-js-api"],
|
||||||
|
},
|
||||||
|
webpack: config => {
|
||||||
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||||
config.resolve.alias["@mswjs/interceptors"] = false;
|
config.resolve.alias["@mswjs/interceptors"] = false;
|
||||||
}
|
}
|
||||||
|
|||||||
19
package.json
19
package.json
@ -4,10 +4,19 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"msw-init": "msw init public/ --save",
|
"msw-init": "msw init public/ --save",
|
||||||
"dev": "next dev --turbopack",
|
"dev": "cross-env SASS_SILENCE_DEPRECATION_WARNINGS=1 next dev --turbopack -p 4000",
|
||||||
|
"dev:debug": "node --inspect-brk ./node_modules/next/dist/bin/next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"format:staged": "prettier --write",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"check-all": "npm run type-check && npm run lint && npm run format:check",
|
||||||
|
"fix-all": "npm run lint:fix && npm run format",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@ -26,8 +35,11 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-date-range": "^2.0.1",
|
"react-date-range": "^2.0.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
"redux-observable": "^3.0.0-rc.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
@ -39,9 +51,12 @@
|
|||||||
"@types/react-date-range": "^1.4.10",
|
"@types/react-date-range": "^1.4.10",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-redux": "^7.1.34",
|
"@types/react-redux": "^7.1.34",
|
||||||
|
"@types/redux-persist": "^4.3.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.3",
|
"eslint-config-next": "15.3.3",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"msw": "^2.10.2",
|
"msw": "^2.10.2",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"msw": {
|
"msw": {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { IEditUserForm } from "@/app/features/UserRoles/User.interfaces";
|
import { IEditUserForm } from "@/app/features/UserRoles/User.interfaces";
|
||||||
|
|
||||||
export async function addUser(data: IEditUserForm) {
|
export async function addUser(data: IEditUserForm) {
|
||||||
const res = await fetch("/api/dashboard/admin/users", {
|
const res = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|||||||
49
yarn.lock
49
yarn.lock
@ -1036,6 +1036,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype "^3.0.2"
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
"@types/redux-persist@^4.3.1":
|
||||||
|
version "4.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/redux-persist/-/redux-persist-4.3.1.tgz#aa4c876859e0bea5155e5f7980e5b8c4699dc2e6"
|
||||||
|
integrity sha512-YkMnMUk+4//wPtiSTMfsxST/F9Gh9sPWX0LVxHuOidGjojHtMdpep2cYvQgfiDMnj34orXyZI+QJCQMZDlafKA==
|
||||||
|
dependencies:
|
||||||
|
redux-persist "*"
|
||||||
|
|
||||||
"@types/statuses@^2.0.4":
|
"@types/statuses@^2.0.4":
|
||||||
version "2.0.6"
|
version "2.0.6"
|
||||||
resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz"
|
resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz"
|
||||||
@ -2401,6 +2408,11 @@ globalthis@^1.0.4:
|
|||||||
define-properties "^1.2.1"
|
define-properties "^1.2.1"
|
||||||
gopd "^1.0.1"
|
gopd "^1.0.1"
|
||||||
|
|
||||||
|
goober@^2.1.16:
|
||||||
|
version "2.1.18"
|
||||||
|
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.18.tgz#b72d669bd24d552d441638eee26dfd5716ea6442"
|
||||||
|
integrity sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==
|
||||||
|
|
||||||
gopd@^1.0.1, gopd@^1.2.0:
|
gopd@^1.0.1, gopd@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
|
resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
|
||||||
@ -2471,6 +2483,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react-is "^16.7.0"
|
react-is "^16.7.0"
|
||||||
|
|
||||||
|
husky@^9.1.7:
|
||||||
|
version "9.1.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d"
|
||||||
|
integrity sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==
|
||||||
|
|
||||||
ignore@^5.2.0:
|
ignore@^5.2.0:
|
||||||
version "5.3.2"
|
version "5.3.2"
|
||||||
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
|
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
|
||||||
@ -3160,6 +3177,11 @@ prelude-ls@^1.2.1:
|
|||||||
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
|
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
|
||||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||||
|
|
||||||
|
prettier@^3.6.2:
|
||||||
|
version "3.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393"
|
||||||
|
integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==
|
||||||
|
|
||||||
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||||
version "15.8.1"
|
version "15.8.1"
|
||||||
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
|
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
|
||||||
@ -3208,6 +3230,14 @@ react-dom@^19.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
scheduler "^0.26.0"
|
scheduler "^0.26.0"
|
||||||
|
|
||||||
|
react-hot-toast@^2.6.0:
|
||||||
|
version "2.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.6.0.tgz#4ada6ed3c75c5e42a90d562f55665ff37ee1442b"
|
||||||
|
integrity sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==
|
||||||
|
dependencies:
|
||||||
|
csstype "^3.1.3"
|
||||||
|
goober "^2.1.16"
|
||||||
|
|
||||||
react-is@^16.13.1, react-is@^16.7.0:
|
react-is@^16.13.1, react-is@^16.7.0:
|
||||||
version "16.13.1"
|
version "16.13.1"
|
||||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
||||||
@ -3286,6 +3316,16 @@ recharts@^2.15.3:
|
|||||||
tiny-invariant "^1.3.1"
|
tiny-invariant "^1.3.1"
|
||||||
victory-vendor "^36.6.8"
|
victory-vendor "^36.6.8"
|
||||||
|
|
||||||
|
redux-observable@^3.0.0-rc.2:
|
||||||
|
version "3.0.0-rc.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-3.0.0-rc.2.tgz#baef603781c5dabd9ddd70526357076cd5c128a2"
|
||||||
|
integrity sha512-gG/pWIKgSrcTyyavm2so5tc7tuyCQ47p3VdCAG6wt+CV0WGhDr50cMQHLcYKxFZSGgTm19a8ZmyfJGndmGDpYg==
|
||||||
|
|
||||||
|
redux-persist@*:
|
||||||
|
version "6.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8"
|
||||||
|
integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==
|
||||||
|
|
||||||
redux-thunk@^3.1.0:
|
redux-thunk@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz"
|
resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz"
|
||||||
@ -3384,6 +3424,13 @@ run-parallel@^1.1.9:
|
|||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask "^1.2.2"
|
queue-microtask "^1.2.2"
|
||||||
|
|
||||||
|
rxjs@^7.8.2:
|
||||||
|
version "7.8.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b"
|
||||||
|
integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
safe-array-concat@^1.1.3:
|
safe-array-concat@^1.1.3:
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz"
|
resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz"
|
||||||
@ -3777,7 +3824,7 @@ tsconfig-paths@^3.15.0:
|
|||||||
minimist "^1.2.6"
|
minimist "^1.2.6"
|
||||||
strip-bom "^3.0.0"
|
strip-bom "^3.0.0"
|
||||||
|
|
||||||
tslib@^2.4.0, tslib@^2.8.0:
|
tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0:
|
||||||
version "2.8.1"
|
version "2.8.1"
|
||||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
|
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user