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
|
||||
|
||||
|
||||
```bash
|
||||
git clone https://git.luckyigaming.com/Mitchell/payment-backoffice.git
|
||||
cd backoffice
|
||||
|
||||
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 { cookies } from "next/headers";
|
||||
import { SignJWT } from "jose";
|
||||
import { decodeJwt } from "jose";
|
||||
|
||||
// Secret key for JWT signing (in production, use environment variable)
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
|
||||
const COOKIE_NAME = "auth_token";
|
||||
|
||||
const TOKEN_EXPIRY = 60 * 60 * 12; // 12 hours (in seconds)
|
||||
|
||||
// This is your POST handler for the login endpoint
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
// --- Replace with your ACTUAL authentication logic ---
|
||||
// In a real application, you would:
|
||||
// 1. Query your database for the user by email.
|
||||
// 2. Hash the provided password and compare it to the stored hashed password.
|
||||
// 3. If credentials match, generate a secure JWT (JSON Web Token) or session ID.
|
||||
// 4. Store the token/session ID securely (e.g., in a database or Redis).
|
||||
|
||||
// Mock authentication for demonstration purposes:
|
||||
if (email === "admin@example.com" && password === "password123") {
|
||||
// Create JWT token with expiration
|
||||
const token = await new SignJWT({
|
||||
email,
|
||||
role: "admin",
|
||||
iat: Math.floor(Date.now() / 1000), // issued at
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) + TOKEN_EXPIRY)
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
// Set the authentication token as an HTTP-only cookie
|
||||
// HTTP-only cookies are crucial for security as they cannot be accessed by client-side JavaScript,
|
||||
// which mitigates XSS attacks.
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("auth_token", token, {
|
||||
httpOnly: true, // IMPORTANT: Makes the cookie inaccessible to client-side scripts
|
||||
secure: process.env.NODE_ENV === "production", // Use secure in production (HTTPS)
|
||||
maxAge: TOKEN_EXPIRY, // 24 hours
|
||||
path: "/", // Available across the entire site
|
||||
sameSite: "lax", // Protects against CSRF
|
||||
});
|
||||
// Call backend login
|
||||
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errJson = await safeJson(resp);
|
||||
return NextResponse.json(
|
||||
{ success: true, message: "Login successful" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid credentials" },
|
||||
{ status: 401 }
|
||||
{ success: false, message: errJson?.message || "Login failed" },
|
||||
{ status: resp.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const token: string | undefined = data?.token;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "No token returned from backend" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// Decode JWT token to extract MustChangePassword
|
||||
let mustChangePassword = false;
|
||||
let maxAge = 60 * 60 * 12; // fallback to 12h
|
||||
|
||||
try {
|
||||
const payload = decodeJwt(token);
|
||||
|
||||
// Extract exp if present
|
||||
if (payload?.exp) {
|
||||
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
|
||||
if (secondsLeft > 0) maxAge = secondsLeft;
|
||||
}
|
||||
|
||||
// Extract MustChangePassword flag if it exists
|
||||
if (typeof payload?.MustChangePassword === "boolean") {
|
||||
mustChangePassword = payload.MustChangePassword;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to decode JWT:", err);
|
||||
}
|
||||
|
||||
// Set the cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
user: data.user,
|
||||
success: true,
|
||||
message: "Login successful",
|
||||
must_change_password: mustChangePassword,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Login API error:", error);
|
||||
console.error("Login proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function safeJson(resp: Response) {
|
||||
try {
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
// This is your DELETE handler for the logout endpoint
|
||||
export async function DELETE() {
|
||||
try {
|
||||
// Clear the authentication cookie.
|
||||
// This MUST match the name of the cookie set during login.
|
||||
// In your login handler, the cookie is named "auth_token".
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete("auth_token");
|
||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:3000";
|
||||
const COOKIE_NAME = "auth_token";
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: "Logged out successfully" },
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Logout API error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Internal server error during logout" },
|
||||
{ status: 500 },
|
||||
);
|
||||
export async function DELETE() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
await fetch(`${BE_BASE_URL}/logout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`, // satisfy requireJwt
|
||||
},
|
||||
body: JSON.stringify({ token }), // satisfy body check
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("BE /logout failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
return NextResponse.json({ success: true, message: "Logged out" });
|
||||
}
|
||||
|
||||
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";
|
||||
import { users, type User } from "../../mockData"; // adjust relative path
|
||||
// app/api/users/[id]/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
type UpdateUserBody = Partial<{
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
}>;
|
||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
||||
const COOKIE_NAME = "auth_token";
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
console.log("[PATCH /users] - params", params);
|
||||
const { id } = params;
|
||||
const body = await request.json();
|
||||
// Get the auth token from cookies
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = cookies();
|
||||
const token = (await cookieStore).get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ message: "Missing Authorization header" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${BE_BASE_URL}/users/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (err: any) {
|
||||
console.error("Proxy PATCH /users error:", err);
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error", error: err.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { firstName, lastName, email, phone, role } = body as UpdateUserBody;
|
||||
|
||||
const userIndex = users.findIndex((u: User) => u.id === id);
|
||||
if (userIndex === -1) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingUser = users[userIndex];
|
||||
|
||||
const updatedUser: User = {
|
||||
...existingUser,
|
||||
firstName: firstName ?? existingUser.firstName,
|
||||
lastName: lastName ?? existingUser.lastName,
|
||||
email: email ?? existingUser.email,
|
||||
phone: phone ?? existingUser.phone,
|
||||
authorities: role ? [role] : existingUser.authorities ?? [],
|
||||
};
|
||||
|
||||
users[userIndex] = updatedUser;
|
||||
|
||||
return NextResponse.json(updatedUser, { status: 200 });
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
if (merchantId) {
|
||||
filteredApproveRows = filteredApproveRows.filter((tx) =>
|
||||
tx.merchantId.toString().includes(merchantId),
|
||||
filteredApproveRows = filteredApproveRows.filter(tx =>
|
||||
tx.merchantId.toString().includes(merchantId)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -16,23 +16,22 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (actionType) {
|
||||
filteredRows = filteredRows.filter(
|
||||
(tx) =>
|
||||
tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase(),
|
||||
tx => tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
if (affectedUserId) {
|
||||
filteredRows = filteredRows.filter(
|
||||
(tx) => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase(),
|
||||
tx => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
if (adminId) {
|
||||
filteredRows = filteredRows.filter((tx) => tx.adminId === adminId);
|
||||
filteredRows = filteredRows.filter(tx => tx.adminId === adminId);
|
||||
}
|
||||
if (adminUsername) {
|
||||
filteredRows = filteredRows.filter(
|
||||
(tx) => tx.adminUsername === adminUsername,
|
||||
tx => tx.adminUsername === adminUsername
|
||||
);
|
||||
}
|
||||
|
||||
@ -46,11 +45,11 @@ export async function GET(request: NextRequest) {
|
||||
{
|
||||
error: "Invalid date range",
|
||||
},
|
||||
{ status: 400 },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
filteredRows = filteredRows.filter((tx) => {
|
||||
filteredRows = filteredRows.filter(tx => {
|
||||
const txDate = new Date(tx.timeStampOfTheAction);
|
||||
|
||||
// Validate if the timestamp is a valid date
|
||||
|
||||
@ -232,8 +232,7 @@ export const allTransactionsExtraColumns: GridColDef[] = [
|
||||
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
|
||||
];
|
||||
|
||||
export const extraColumns = ["currency", "errorInfo", "fraudScore"]
|
||||
|
||||
export const extraColumns = ["currency", "errorInfo", "fraudScore"];
|
||||
|
||||
export const allTransactionsSearchLabels = [
|
||||
{ label: "User", field: "userId", type: "text" },
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { allTransactionDummyData, allTransactionsColumns, allTransactionsSearchLabels, extraColumns } from "./mockData";
|
||||
import {
|
||||
allTransactionDummyData,
|
||||
allTransactionsColumns,
|
||||
allTransactionsSearchLabels,
|
||||
extraColumns,
|
||||
} from "./mockData";
|
||||
|
||||
// import { formatToDateTimeString } from "@/app/utils/formatDate";
|
||||
|
||||
@ -20,29 +25,29 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (userId) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.userId.toString() === userId,
|
||||
tx => tx.userId.toString() === userId
|
||||
);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.status.toLowerCase() === status.toLowerCase(),
|
||||
tx => tx.status.toLowerCase() === status.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
if (depositMethod) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase(),
|
||||
tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
|
||||
);
|
||||
}
|
||||
if (merchandId) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.merchandId.toString() === merchandId,
|
||||
tx => tx.merchandId.toString() === merchandId
|
||||
);
|
||||
}
|
||||
if (transactionId) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.transactionId.toString() === transactionId,
|
||||
tx => tx.transactionId.toString() === transactionId
|
||||
);
|
||||
}
|
||||
|
||||
@ -55,11 +60,11 @@ export async function GET(request: NextRequest) {
|
||||
{
|
||||
error: "Invalid date range",
|
||||
},
|
||||
{ status: 400 },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
filteredTransactions = filteredTransactions.filter((tx) => {
|
||||
filteredTransactions = filteredTransactions.filter(tx => {
|
||||
const txDate = new Date(tx.dateTime);
|
||||
|
||||
if (isNaN(txDate.getTime())) {
|
||||
|
||||
@ -24,29 +24,29 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (userId) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.userId.toString() === userId,
|
||||
tx => tx.userId.toString() === userId
|
||||
);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.status.toLowerCase() === status.toLowerCase(),
|
||||
tx => tx.status.toLowerCase() === status.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
if (depositMethod) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase(),
|
||||
tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
|
||||
);
|
||||
}
|
||||
if (merchandId) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.merchandId.toString() === merchandId,
|
||||
tx => tx.merchandId.toString() === merchandId
|
||||
);
|
||||
}
|
||||
if (transactionId) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.transactionId.toString() === transactionId,
|
||||
tx => tx.transactionId.toString() === transactionId
|
||||
);
|
||||
}
|
||||
|
||||
@ -59,11 +59,11 @@ export async function GET(request: NextRequest) {
|
||||
{
|
||||
error: "Invalid date range",
|
||||
},
|
||||
{ status: 400 },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
filteredTransactions = filteredTransactions.filter((tx) => {
|
||||
filteredTransactions = filteredTransactions.filter(tx => {
|
||||
const txDate = new Date(tx.dateTime);
|
||||
|
||||
if (isNaN(txDate.getTime())) {
|
||||
|
||||
@ -7,7 +7,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Error",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -26,7 +26,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Error",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -45,7 +45,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Completed",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -62,7 +62,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049136973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Completed",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -79,7 +79,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Error",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -98,7 +98,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Error",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -117,7 +117,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Error",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -136,7 +136,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Card",
|
||||
status: "Pending",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -153,7 +153,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Inprogress",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -170,7 +170,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Error",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -189,7 +189,7 @@ export const withdrawalTransactionDummyData = [
|
||||
transactionId: 1049131973,
|
||||
withdrawalMethod: "Bank Transfer",
|
||||
status: "Error",
|
||||
options: [
|
||||
options: [
|
||||
{ value: "Pending", label: "Pending" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Inprogress", label: "Inprogress" },
|
||||
@ -209,7 +209,7 @@ export const withdrawalTransactionsColumns: GridColDef[] = [
|
||||
{ field: "transactionId", headerName: "Transaction ID", width: 130 },
|
||||
{ field: "withdrawalMethod", headerName: "Withdrawal Method", width: 130 },
|
||||
{ field: "status", headerName: "Status", width: 130 },
|
||||
{ field: "actions", headerName: "Actions", width: 150 },
|
||||
{ field: "actions", headerName: "Actions", width: 150 },
|
||||
{ field: "amount", headerName: "Amount", width: 130 },
|
||||
{ field: "dateTime", headerName: "Date / Time", width: 130 },
|
||||
{ field: "errorInfo", headerName: "Error Info", width: 130 },
|
||||
|
||||
@ -19,13 +19,13 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (userId) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.userId.toString() === userId,
|
||||
tx => tx.userId.toString() === userId
|
||||
);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.status.toLowerCase() === status.toLowerCase(),
|
||||
tx => tx.status.toLowerCase() === status.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
@ -38,11 +38,11 @@ export async function GET(request: NextRequest) {
|
||||
{
|
||||
error: "Invalid date range",
|
||||
},
|
||||
{ status: 400 },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
filteredTransactions = filteredTransactions.filter((tx) => {
|
||||
filteredTransactions = filteredTransactions.filter(tx => {
|
||||
const txDate = new Date(tx.dateTime);
|
||||
|
||||
if (isNaN(txDate.getTime())) {
|
||||
@ -55,8 +55,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (withdrawalMethod) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) =>
|
||||
tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase(),
|
||||
tx => tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,65 +1,83 @@
|
||||
interface Transaction {
|
||||
userId: number | string;
|
||||
interface Transaction {
|
||||
userId: number | string;
|
||||
date: string;
|
||||
method: string;
|
||||
amount: number | string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const deposits: Transaction[] = [
|
||||
{ userId: 17, date: "2025-08-01 10:10", method: "CC", amount: 120, status: "approved" },
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-28 14:35",
|
||||
method: "Bank Transfer",
|
||||
amount: 250,
|
||||
status: "approved",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-20 09:05",
|
||||
method: "PayPal",
|
||||
amount: 75,
|
||||
status: "pending",
|
||||
},
|
||||
{ userId: 17,
|
||||
date: "2025-07-11 17:12", method: "CC", amount: 300, status: "rejected" },
|
||||
{ userId: 17,
|
||||
date: "2025-07-01 12:42", method: "CC", amount: 180, status: "approved" },
|
||||
];
|
||||
export const deposits: Transaction[] = [
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-08-01 10:10",
|
||||
method: "CC",
|
||||
amount: 120,
|
||||
status: "approved",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-28 14:35",
|
||||
method: "Bank Transfer",
|
||||
amount: 250,
|
||||
status: "approved",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-20 09:05",
|
||||
method: "PayPal",
|
||||
amount: 75,
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-11 17:12",
|
||||
method: "CC",
|
||||
amount: 300,
|
||||
status: "rejected",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-01 12:42",
|
||||
method: "CC",
|
||||
amount: 180,
|
||||
status: "approved",
|
||||
},
|
||||
];
|
||||
|
||||
export const withdrawals: Transaction[] = [
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-08-02 11:20",
|
||||
method: "Crypto",
|
||||
amount: 95,
|
||||
status: "processing",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-29 16:45",
|
||||
method: "Bank Transfer",
|
||||
amount: 220,
|
||||
status: "approved",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-21 15:10",
|
||||
method: "eWallet",
|
||||
amount: 60,
|
||||
status: "pending",
|
||||
},
|
||||
{ userId: 17,
|
||||
date: "2025-07-12 13:33",
|
||||
method: "Crypto",
|
||||
amount: 120,
|
||||
status: "approved",
|
||||
},
|
||||
{ userId: 17,
|
||||
date: "2025-07-03 08:50",
|
||||
method: "Bank Transfer",
|
||||
amount: 150,
|
||||
status: "rejected",
|
||||
},
|
||||
];
|
||||
export const withdrawals: Transaction[] = [
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-08-02 11:20",
|
||||
method: "Crypto",
|
||||
amount: 95,
|
||||
status: "processing",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-29 16:45",
|
||||
method: "Bank Transfer",
|
||||
amount: 220,
|
||||
status: "approved",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-21 15:10",
|
||||
method: "eWallet",
|
||||
amount: 60,
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-12 13:33",
|
||||
method: "Crypto",
|
||||
amount: 120,
|
||||
status: "approved",
|
||||
},
|
||||
{
|
||||
userId: 17,
|
||||
date: "2025-07-03 08:50",
|
||||
method: "Bank Transfer",
|
||||
amount: 150,
|
||||
status: "rejected",
|
||||
},
|
||||
];
|
||||
|
||||
@ -7,12 +7,15 @@ export async function GET(request: NextRequest) {
|
||||
let filteredDeposits = [...deposits];
|
||||
let filteredwithdrawals = [...withdrawals];
|
||||
|
||||
if( userId ){
|
||||
filteredDeposits = filteredDeposits.filter((item) => item.userId.toString() === userId.toString())
|
||||
filteredwithdrawals = filteredwithdrawals.filter((item) => item.userId.toString() === userId.toString())
|
||||
if (userId) {
|
||||
filteredDeposits = filteredDeposits.filter(
|
||||
item => item.userId.toString() === userId.toString()
|
||||
);
|
||||
filteredwithdrawals = filteredwithdrawals.filter(
|
||||
item => item.userId.toString() === userId.toString()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
deposits: filteredDeposits,
|
||||
withdrawals: filteredwithdrawals,
|
||||
|
||||
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);
|
||||
position: relative;
|
||||
min-width: 320px;
|
||||
max-width: 90vw;
|
||||
max-width: 60vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
padding: 2rem 1.5rem 1.5rem 1.5rem;
|
||||
@ -49,5 +49,5 @@
|
||||
margin-top: 1rem;
|
||||
font-size: 1rem;
|
||||
color: #222;
|
||||
width: 500px;
|
||||
min-width: 450px;
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
>
|
||||
<div
|
||||
className={`modal${className ? " " + className : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
data-testid="modal-content"
|
||||
>
|
||||
<button
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
.page-link__container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 1px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 1px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background-color: var(--hover-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-link__text {
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background-color: var(--hover-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-link__text {
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,43 +1,43 @@
|
||||
.search-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 16px;
|
||||
padding: 4px 8px;
|
||||
margin: 4px;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 16px;
|
||||
padding: 4px 8px;
|
||||
margin: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
margin-right: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.chip-label.bold {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chip-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
color: #333;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.clear-all {
|
||||
margin-left: 8px;
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
border: none;
|
||||
color: black;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
border: none;
|
||||
color: black;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ const SearchFilters = ({ filters }: SearchFiltersProps) => {
|
||||
|
||||
const allFilters = [
|
||||
...Object.entries(filters).filter(
|
||||
([key]) => key !== "dateTime_start" && key !== "dateTime_end",
|
||||
([key]) => key !== "dateTime_start" && key !== "dateTime_end"
|
||||
),
|
||||
...(hasDateRange
|
||||
? [
|
||||
@ -65,7 +65,7 @@ const SearchFilters = ({ filters }: SearchFiltersProps) => {
|
||||
return (
|
||||
<div className="search-filters">
|
||||
{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) && (
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import Users from "@/app/features/Pages/Admin/Users/users";
|
||||
|
||||
export default async function BackOfficeUsersPage() {
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BASE_URL}`
|
||||
: "http://localhost:3000";
|
||||
const res = await fetch(`${baseUrl}/api/dashboard/admin/users`, {
|
||||
cache: "no-store", // 👈 disables caching for SSR freshness
|
||||
});
|
||||
const users = await res.json();
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BASE_URL}`
|
||||
: "http://localhost:4000";
|
||||
// const res = await fetch(`${baseUrl}/api/dashboard/admin/users`, {
|
||||
// cache: "no-store", // 👈 disables caching for SSR freshness
|
||||
// });
|
||||
// const users = await res.json();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Users users={users} />
|
||||
<Users users={[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import {
|
||||
ApproveTable,
|
||||
} from "@/app/features/Pages/Approve/Approve";
|
||||
import { ApproveTable } from "@/app/features/Pages/Approve/Approve";
|
||||
import { getApproves } from "@/app/services/approve";
|
||||
|
||||
|
||||
export default async function Approve({
|
||||
searchParams,
|
||||
}: {
|
||||
@ -21,8 +18,5 @@ export default async function Approve({
|
||||
const query = new URLSearchParams(safeParams).toString();
|
||||
const data = await getApproves({ query });
|
||||
|
||||
return (
|
||||
<ApproveTable data={data} />
|
||||
);
|
||||
};
|
||||
|
||||
return <ApproveTable data={data} />;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// 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() {
|
||||
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__icon {
|
||||
font-weight: bold;
|
||||
color: #4ecdc4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.account-iq__icon {
|
||||
font-weight: bold;
|
||||
color: #4ecdc4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||
});
|
||||
router.push(`?${updatedParams.toString()}`);
|
||||
}, 500),
|
||||
[router],
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleFieldChange = (field: string, value: string) => {
|
||||
@ -132,9 +132,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formValues[field] || ""}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(field, e.target.value)
|
||||
}
|
||||
onChange={e => handleFieldChange(field, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -142,7 +140,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={formValues[field] || ""}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleFieldChange(field, e.target.value)
|
||||
}
|
||||
displayEmpty
|
||||
@ -150,7 +148,7 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||
<MenuItem value="">
|
||||
<em>{label}</em>
|
||||
</MenuItem>
|
||||
{options?.map((option) => (
|
||||
{options?.map(option => (
|
||||
<MenuItem value={option} key={option}>
|
||||
{option}
|
||||
</MenuItem>
|
||||
@ -168,14 +166,14 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||
? new Date(formValues[`${field}_start`])
|
||||
: null
|
||||
}
|
||||
onChange={(newValue) => {
|
||||
onChange={newValue => {
|
||||
if (!newValue)
|
||||
return handleFieldChange(`${field}_start`, "");
|
||||
const start = new Date(newValue);
|
||||
start.setHours(0, 0, 0, 0); // force start of day
|
||||
handleFieldChange(
|
||||
`${field}_start`,
|
||||
start.toISOString(),
|
||||
start.toISOString()
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
@ -189,14 +187,14 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||
? new Date(formValues[`${field}_end`])
|
||||
: null
|
||||
}
|
||||
onChange={(newValue) => {
|
||||
onChange={newValue => {
|
||||
if (!newValue)
|
||||
return handleFieldChange(`${field}_end`, "");
|
||||
const end = new Date(newValue);
|
||||
end.setHours(23, 59, 59, 999); // force end of day
|
||||
handleFieldChange(
|
||||
`${field}_end`,
|
||||
end.toISOString(),
|
||||
end.toISOString()
|
||||
);
|
||||
}}
|
||||
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) */
|
||||
@use "sass:color";
|
||||
|
||||
// Variables for consistent styling
|
||||
$primary-color: #2563eb; // Blue-600 equivalent
|
||||
@ -30,7 +31,8 @@ $bg-color-white: #ffffff;
|
||||
.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),
|
||||
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%;
|
||||
@ -79,7 +81,9 @@ $bg-color-white: #ffffff;
|
||||
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
|
||||
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;
|
||||
@ -114,13 +118,19 @@ $bg-color-white: #ffffff;
|
||||
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
|
||||
transition:
|
||||
background-color 0.3s ease-in-out,
|
||||
box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out
|
||||
&:hover {
|
||||
background-color: darken($primary-color, 5%); // blue-700 equivalent
|
||||
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),
|
||||
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 {
|
||||
@ -162,7 +172,8 @@ $bg-color-white: #ffffff;
|
||||
max-width: 56rem; // max-w-4xl
|
||||
background-color: $bg-color-white;
|
||||
border-radius: 0.75rem; // rounded-xl
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
|
||||
padding: 2rem; // p-8
|
||||
text-align: center;
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "./LoginModal.scss"; // Adjust path based on your actual structure
|
||||
import { clearAuthMessage } from "@/app/redux/auth/authSlice";
|
||||
|
||||
// Define the props interface for LoginModal
|
||||
type LoginModalProps = {
|
||||
@ -19,12 +20,8 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
||||
|
||||
// Effect to clear authentication messages when email or password inputs change
|
||||
useEffect(() => {
|
||||
// clearAuthMessage();
|
||||
}, [
|
||||
email,
|
||||
password,
|
||||
// clearAuthMessage
|
||||
]); // Dependency array ensures effect runs when these change
|
||||
clearAuthMessage();
|
||||
}, [email, password]); // Dependency array ensures effect runs when these change
|
||||
|
||||
// Handler for form submission
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
@ -47,7 +44,7 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
||||
className="login-form__input"
|
||||
placeholder="admin@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@ -64,7 +61,7 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
||||
className="login-form__input"
|
||||
placeholder="password123"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@ -76,7 +73,6 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
||||
disabled={isLoading} // Disable button while loading
|
||||
>
|
||||
{isLoading ? (
|
||||
// SVG spinner for loading state
|
||||
<svg
|
||||
className="login-form__spinner"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@ -67,20 +67,20 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
||||
|
||||
const handleStatusSave = () => {
|
||||
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(
|
||||
rows.map((row) =>
|
||||
row.id === selectedRowId ? { ...row, status: newStatus } : row,
|
||||
),
|
||||
rows.map(row =>
|
||||
row.id === selectedRowId ? { ...row, status: newStatus } : row
|
||||
)
|
||||
);
|
||||
setModalOpen(false);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const getColumnsWithDropdown = (columns: TColumn[]): GridColDef[] => {
|
||||
return columns.map((col) => {
|
||||
return columns.map(col => {
|
||||
if (col.field === "status") {
|
||||
return {
|
||||
...col,
|
||||
@ -130,53 +130,52 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
||||
};
|
||||
}
|
||||
|
||||
if (col.field === "userId") {
|
||||
return {
|
||||
...col,
|
||||
headerAlign: "center",
|
||||
align: "center",
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
px: 1,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // keep row click from firing when clicking inside
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: "0.875rem",
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{params.value}
|
||||
</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 === "userId") {
|
||||
return {
|
||||
...col,
|
||||
headerAlign: "center",
|
||||
align: "center",
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
px: 1,
|
||||
}}
|
||||
onClick={e => e.stopPropagation()} // keep row click from firing when clicking inside
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: "0.875rem",
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{params.value}
|
||||
</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") {
|
||||
return {
|
||||
...col,
|
||||
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;
|
||||
status?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
@ -188,7 +187,7 @@ if (col.field === "userId") {
|
||||
return (
|
||||
<Select
|
||||
value={params.value ?? row.status}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleStatusChange(params.id as number, e.target.value)
|
||||
}
|
||||
size="small"
|
||||
@ -197,9 +196,9 @@ if (col.field === "userId") {
|
||||
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||
"& .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}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
@ -218,7 +217,7 @@ if (col.field === "userId") {
|
||||
if (extraColumns && extraColumns.length > 0) {
|
||||
filteredColumns = showExtraColumns
|
||||
? tableColumns
|
||||
: tableColumns.filter((col) => !extraColumns.includes(col.field));
|
||||
: tableColumns.filter(col => !extraColumns.includes(col.field));
|
||||
}
|
||||
|
||||
return (
|
||||
@ -235,7 +234,7 @@ if (col.field === "userId") {
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onChange={(e) => console.log(`setSearchQuery(${e.target.value})`)}
|
||||
onChange={e => console.log(`setSearchQuery(${e.target.value})`)}
|
||||
sx={{ width: 300 }}
|
||||
/>
|
||||
<AdvancedSearch labels={tableSearchLabels} />
|
||||
@ -250,7 +249,7 @@ if (col.field === "userId") {
|
||||
{extraColumns && extraColumns.length > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setShowExtraColumns((prev) => !prev)}
|
||||
onClick={() => setShowExtraColumns(prev => !prev)}
|
||||
>
|
||||
{showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"}
|
||||
</Button>
|
||||
@ -281,7 +280,7 @@ if (col.field === "userId") {
|
||||
justifyContent: "center",
|
||||
},
|
||||
}}
|
||||
onCellClick={(params) => {
|
||||
onCellClick={params => {
|
||||
if (params.field !== "actions") {
|
||||
handleClickField(params.field, params.value as string);
|
||||
}
|
||||
@ -305,7 +304,7 @@ if (col.field === "userId") {
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<Select
|
||||
value={fileType}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
setFileType(e.target.value as "csv" | "xls" | "xlsx")
|
||||
}
|
||||
>
|
||||
@ -318,7 +317,7 @@ if (col.field === "userId") {
|
||||
control={
|
||||
<Checkbox
|
||||
checked={onlyCurrentTable}
|
||||
onChange={(e) => setOnlyCurrentTable(e.target.checked)}
|
||||
onChange={e => setOnlyCurrentTable(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Only export current table"
|
||||
@ -335,7 +334,7 @@ if (col.field === "userId") {
|
||||
tableColumns,
|
||||
fileType,
|
||||
onlyCurrentTable,
|
||||
setOpen,
|
||||
setOpen
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@ -46,7 +46,7 @@ const StatusChangeDialog = ({
|
||||
multiline
|
||||
rows={4}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
helperText="Reason must be between 12 and 400 characters"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
.date-range-picker {
|
||||
.date-range-picker__date-typo {
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
.date-range-picker__date-typo {
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ export const DateRangePicker = () => {
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
const handleSelect: DateRangeProps["onChange"] = (ranges) => {
|
||||
const handleSelect: DateRangeProps["onChange"] = ranges => {
|
||||
if (ranges.selection) {
|
||||
setRange([ranges.selection]);
|
||||
if (ranges.selection.endDate !== ranges.selection.startDate) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
.documentation {
|
||||
.documentation__icon {
|
||||
height: auto;
|
||||
}
|
||||
.documentation__icon {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
.fetch-report {
|
||||
padding: 23px;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 23px;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ export const FetchReport = () => {
|
||||
<InputLabel>Select state (defaults to All)</InputLabel>
|
||||
<Select
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
onChange={e => setState(e.target.value)}
|
||||
label="Select state (defaults to All)"
|
||||
>
|
||||
<MenuItem value="successful">Successful</MenuItem>
|
||||
@ -67,7 +67,7 @@ export const FetchReport = () => {
|
||||
<InputLabel>Select PSPs (defaults to All)</InputLabel>
|
||||
<Select
|
||||
value={psp}
|
||||
onChange={(e) => setPsp(e.target.value)}
|
||||
onChange={e => setPsp(e.target.value)}
|
||||
label="Select PSPs (defaults to All)"
|
||||
>
|
||||
<MenuItem value="a1">A1</MenuItem>
|
||||
@ -139,7 +139,7 @@ export const FetchReport = () => {
|
||||
<InputLabel>Select report type</InputLabel>
|
||||
<Select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
onChange={e => setReportType(e.target.value)}
|
||||
label="Select report type"
|
||||
>
|
||||
<MenuItem value="allTransactionsReport">
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
.general-health-card {
|
||||
.general-health-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
.general-health-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.general-health-card__right-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.general-health-card__right-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.general-health-card__stat-items {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.general-health-card__stat-items {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
.static-item {
|
||||
text-align: center;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
text-align: center;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
|
||||
.static-item__percentage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: red;
|
||||
}
|
||||
.static-item__percentage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
||||
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
}, 400),
|
||||
[router, searchParams, searchParamKey],
|
||||
[router, searchParams, searchParamKey]
|
||||
);
|
||||
|
||||
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) => {
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
||||
);
|
||||
setSelected(prev => (checked ? [...prev, id] : prev.filter(x => x !== id)));
|
||||
};
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
setSelected(checked ? rows.map((r) => r.id) : []);
|
||||
setSelected(checked ? rows.map(r => r.id) : []);
|
||||
};
|
||||
|
||||
const handleActionChange = (e: SelectChangeEvent<string>) => {
|
||||
@ -125,7 +123,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
||||
|
||||
const handleStatusSave = () => {
|
||||
console.log(
|
||||
`Status changed for row with ID ${selected}. New status: ${action}. Reason: ${reason}`,
|
||||
`Status changed for row with ID ${selected}. New status: ${action}. Reason: ${reason}`
|
||||
);
|
||||
setModalOpen(false);
|
||||
setReason("");
|
||||
@ -177,7 +175,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
||||
onChange={handleActionChange}
|
||||
size="small"
|
||||
>
|
||||
{actions.map((item) => (
|
||||
{actions.map(item => (
|
||||
<MenuItem key={item.value} value={item.value}>
|
||||
{item.label ?? item.value}
|
||||
</MenuItem>
|
||||
@ -219,7 +217,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
||||
indeterminate={
|
||||
selected.length > 0 && selected.length < rows.length
|
||||
}
|
||||
onChange={(e) => handleToggleAll(e.target.checked)}
|
||||
onChange={e => handleToggleAll(e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@ -257,7 +255,7 @@ export function ApproveTable<T extends { id: string | number }>({
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleCheckboxChange(row.id, e.target.checked)
|
||||
}
|
||||
/>
|
||||
@ -304,7 +302,9 @@ export function ApproveTable<T extends { id: string | number }>({
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box sx={{textAlign: "center"}}>{value as React.ReactNode}</Box>
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
{value as React.ReactNode}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
width: 60%;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
.section-card {
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.section-card__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.section-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
.section-card__title {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.section-card__icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
.section-card__title {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.section-card__icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +1,28 @@
|
||||
.transaction-overview {
|
||||
padding: 23px;
|
||||
padding: 23px;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.transaction-overview__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.transaction-overview__chart-table {
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.transaction-overview__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.transaction-overview__chart-table {
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
.transactions-overview-table {
|
||||
.transactions-overview-table__state-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 73px;
|
||||
.transactions-overview-table__state-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 73px;
|
||||
|
||||
.transactions-overview-table__state {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.transactions-overview-table__state {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
.transactions-waiting-approval {
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@ -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 } from "@/services/roles.services";
|
||||
import { IEditUserForm } from "../User.interfaces";
|
||||
import { COUNTRY_CODES } from "../constants";
|
||||
import { formatPhoneDisplay, validatePhone } from "../utils";
|
||||
|
||||
interface AddUserFormProps {
|
||||
onSuccess?: () => void;
|
||||
@ -13,27 +15,81 @@ interface AddUserFormProps {
|
||||
const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<IEditUserForm>({
|
||||
username: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
role: "",
|
||||
merchants: [],
|
||||
groups: [],
|
||||
jobTitle: "",
|
||||
});
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [phoneError, setPhoneError] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("+1");
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.firstName || !form.lastName || !form.email || !form.role) {
|
||||
// Validate phone number if provided
|
||||
if (form.phone && phoneError) {
|
||||
setError("Please fix phone number errors before submitting.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!form.firstName ||
|
||||
!form.lastName ||
|
||||
!form.email ||
|
||||
form.merchants.length === 0 ||
|
||||
form.groups.length === 0 ||
|
||||
!form.jobTitle
|
||||
) {
|
||||
setError("Please fill in all required fields.");
|
||||
return;
|
||||
}
|
||||
@ -42,7 +98,13 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
await addUser(form);
|
||||
// Format phone number with country code before submission
|
||||
const formattedForm = {
|
||||
...form,
|
||||
phone: form.phone ? formatPhoneDisplay(form.phone, countryCode) : "",
|
||||
};
|
||||
|
||||
await addUser(formattedForm);
|
||||
if (onSuccess) onSuccess();
|
||||
router.refresh(); // <- refreshes the page (SSR re-runs)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -55,6 +117,13 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
||||
|
||||
return (
|
||||
<form className="add-user" onSubmit={handleSubmit}>
|
||||
<input
|
||||
name="username"
|
||||
placeholder="Username"
|
||||
value={form.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="firstName"
|
||||
placeholder="First Name"
|
||||
@ -77,26 +146,126 @@ const AddUserForm: React.FC<AddUserFormProps> = ({ onSuccess }) => {
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="phone"
|
||||
placeholder="Phone (optional)"
|
||||
value={form.phone}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="array-field-container">
|
||||
<label>Merchants:</label>
|
||||
<select
|
||||
name="merchants"
|
||||
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
|
||||
name="role"
|
||||
value={form.role}
|
||||
name="jobTitle"
|
||||
value={form.jobTitle}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="add-user__select"
|
||||
>
|
||||
<option value="">Select Role</option>
|
||||
<option value="ROLE_IIN">ROLE_IIN</option>
|
||||
<option value="ROLE_RULES_ADMIN">ROLE_RULES_ADMIN</option>
|
||||
<option value="ROLE_FIRST_APPROVER">ROLE_FIRST_APPROVER</option>
|
||||
<option value="">Select Job Title</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Reader">Reader</option>
|
||||
<option value="User">User</option>
|
||||
<option value="Manager">Manager</option>
|
||||
<option value="Supervisor">Supervisor</option>
|
||||
<option value="Director">Director</option>
|
||||
</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">
|
||||
<button type="submit" disabled={loading}>
|
||||
|
||||
@ -21,12 +21,12 @@ const EditUser = ({ user }: { user: IUser }) => {
|
||||
const value = e.target.value;
|
||||
if (name === "phone") {
|
||||
const filtered = value.replace(/[^0-9+\-\s()]/g, "");
|
||||
setForm((prev) => ({
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
phone: filtered,
|
||||
}));
|
||||
} else {
|
||||
setForm((prev) => ({
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
export interface IEditUserForm {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
phone: string;
|
||||
merchants: string[];
|
||||
groups: string[];
|
||||
jobTitle: string;
|
||||
}
|
||||
|
||||
export type EditUserField =
|
||||
| "merchants"
|
||||
| "groups"
|
||||
| "jobTitle"
|
||||
| "username"
|
||||
| "firstName"
|
||||
| "lastName"
|
||||
| "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>
|
||||
<Stack direction="row" spacing={1} mt={1} flexWrap="wrap">
|
||||
<Stack direction="row" spacing={1}>
|
||||
{roles.map((role) => (
|
||||
{roles.map(role => (
|
||||
<Chip key={role} label={role} size="small" />
|
||||
))}
|
||||
</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__wifi-icon {
|
||||
height: auto;
|
||||
}
|
||||
.whats-new__wifi-icon {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
.header {
|
||||
.header__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.header__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.header__left-group {
|
||||
width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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__left-group {
|
||||
width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,11 @@ export default function AccountMenu() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const router = useRouter();
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
router.push("/dashboard/settings");
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// Select relevant state from your auth slice
|
||||
const isLoggedIn = useSelector(selectIsLoggedIn);
|
||||
const authStatus = useSelector(selectStatus);
|
||||
@ -42,22 +47,10 @@ export default function AccountMenu() {
|
||||
const isLoggingOut = authStatus === "loading";
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Dispatch the logout thunk
|
||||
const resultAction = 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
|
||||
}
|
||||
// Dispatch the logout thunk - redirection will be handled by the epic
|
||||
await dispatch(logout());
|
||||
};
|
||||
|
||||
console.log("[isLoggedin]", isLoggedIn);
|
||||
|
||||
// Only show the logout button if the user is logged in
|
||||
if (!isLoggedIn) {
|
||||
return null;
|
||||
@ -84,7 +77,7 @@ export default function AccountMenu() {
|
||||
<Typography variant="inherit">Account</Typography>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem>
|
||||
<MenuItem onClick={handleGoToSettings}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
.sidebar-dropdown__container {
|
||||
.page-link__container {
|
||||
color: var(--text-secondary);
|
||||
.page-link__text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.page-link__container {
|
||||
color: var(--text-secondary);
|
||||
.page-link__text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { styled } from '@mui/system';
|
||||
import { styled } from "@mui/system";
|
||||
|
||||
export const MainContent = styled('div')(({ theme }) => ({
|
||||
marginLeft: '240px',
|
||||
export const MainContent = styled("div")(({ theme }) => ({
|
||||
marginLeft: "240px",
|
||||
padding: theme.spacing(3),
|
||||
minHeight: '100vh',
|
||||
width: 'calc(100% - 240px)',
|
||||
}));
|
||||
minHeight: "100vh",
|
||||
width: "calc(100% - 240px)",
|
||||
}));
|
||||
|
||||
@ -12,7 +12,7 @@ const SideBar = () => {
|
||||
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleMenu = (title: string) => {
|
||||
setOpenMenus((prev) => ({ ...prev, [title]: !prev[title] }));
|
||||
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
|
||||
};
|
||||
|
||||
return (
|
||||
@ -24,7 +24,7 @@ const SideBar = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{PAGE_LINKS.map((link) =>
|
||||
{PAGE_LINKS.map(link =>
|
||||
link.children ? (
|
||||
<div key={link.title}>
|
||||
<button
|
||||
@ -43,7 +43,7 @@ const SideBar = () => {
|
||||
</button>
|
||||
{openMenus[link.title] && (
|
||||
<div className="sidebar__submenu">
|
||||
{link.children.map((child) => (
|
||||
{link.children.map(child => (
|
||||
<PageLinks
|
||||
key={child.path}
|
||||
title={child.title}
|
||||
|
||||
@ -11,12 +11,11 @@ import InsightsIcon from "@mui/icons-material/Insights";
|
||||
import ListAltIcon from "@mui/icons-material/ListAlt";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import FactCheckIcon from "@mui/icons-material/FactCheck";
|
||||
|
||||
|
||||
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces";
|
||||
|
||||
export const PAGE_LINKS: ISidebarLink[] = [
|
||||
@ -25,7 +24,8 @@ export const PAGE_LINKS: ISidebarLink[] = [
|
||||
{
|
||||
title: "Transaction",
|
||||
path: "/dashboard/transactions",
|
||||
icon: AccountBalanceWalletIcon, children: [
|
||||
icon: AccountBalanceWalletIcon,
|
||||
children: [
|
||||
{
|
||||
title: "Deposits",
|
||||
path: "/dashboard/transactions/deposits",
|
||||
|
||||
@ -36,10 +36,13 @@ export function useTokenExpiration() {
|
||||
}, logoutDelay);
|
||||
|
||||
// Also set up periodic checks every 5 minutes
|
||||
const checkInterval = setInterval(() => {
|
||||
// Re-dispatch checkAuthStatus to get updated expiration time
|
||||
// This will be handled by the ReduxProvider
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
const checkInterval = setInterval(
|
||||
() => {
|
||||
// Re-dispatch checkAuthStatus to get updated expiration time
|
||||
// This will be handled by the ReduxProvider
|
||||
},
|
||||
5 * 60 * 1000
|
||||
); // 5 minutes
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import ThemeRegistry from "@/config/ThemeRegistry";
|
||||
// app/layout.tsx
|
||||
import type { Metadata } from "next";
|
||||
import ReduxProvider from "./redux/ReduxProvider";
|
||||
import ThemeRegistry from "@/config/ThemeRegistry";
|
||||
import ReduxProvider from "./ReduxProvider"; // moved into app/
|
||||
import { AuthBootstrap } from "./AuthBootstrap"; // moved into app/
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import "../styles/globals.scss";
|
||||
import { InitializeAuth } from "./redux/InitializeAuth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your App",
|
||||
@ -18,8 +20,10 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<body>
|
||||
<ReduxProvider>
|
||||
<InitializeAuth />
|
||||
{/* Bootstraps session validation + redirect if invalid */}
|
||||
<AuthBootstrap />
|
||||
<ThemeRegistry>{children}</ThemeRegistry>
|
||||
<Toaster position="top-right" reverseOrder={false} />
|
||||
</ReduxProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -10,9 +10,15 @@ import {
|
||||
selectAuthMessage,
|
||||
selectIsLoggedIn,
|
||||
selectStatus,
|
||||
selectUser,
|
||||
} from "../redux/auth/selectors";
|
||||
import { clearAuthMessage, login } from "../redux/auth/authSlice";
|
||||
import {
|
||||
clearAuthMessage,
|
||||
login,
|
||||
changePassword,
|
||||
} from "../redux/auth/authSlice";
|
||||
import "./page.scss";
|
||||
import { ChangePassword } from "../features/Auth/ChangePassword/ChangePassword";
|
||||
|
||||
export default function LoginPageClient() {
|
||||
const router = useRouter();
|
||||
@ -22,8 +28,14 @@ export default function LoginPageClient() {
|
||||
const isLoggedIn = useSelector(selectIsLoggedIn);
|
||||
const status = useSelector(selectStatus);
|
||||
const authMessage = useSelector(selectAuthMessage);
|
||||
const user = useSelector(selectUser);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const [redirectMessage, setRedirectMessage] = useState<string>("");
|
||||
const [mustChangePassword, setMustChangePassword] = useState<boolean>(
|
||||
reason === "change-password"
|
||||
);
|
||||
|
||||
console.log("[DEBUG] [reason]:", reason === "change-password");
|
||||
|
||||
useEffect(() => {
|
||||
// Set message based on redirect reason
|
||||
@ -33,14 +45,22 @@ export default function LoginPageClient() {
|
||||
setRedirectMessage("Invalid session detected. Please log in again.");
|
||||
} else if (reason === "no-token") {
|
||||
setRedirectMessage("Please log in to access the backoffice.");
|
||||
} else if (reason === "change-password") {
|
||||
setRedirectMessage(
|
||||
"Please change your password to access the backoffice."
|
||||
);
|
||||
}
|
||||
}, [reason]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && status === "succeeded") {
|
||||
setMustChangePassword(reason === "change-password" ? true : false);
|
||||
}, [reason]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && status === "succeeded" && !mustChangePassword) {
|
||||
router.replace(redirectPath);
|
||||
}
|
||||
}, [isLoggedIn, status, router, redirectPath]);
|
||||
}, [isLoggedIn, status, mustChangePassword, router, redirectPath]);
|
||||
|
||||
const handleLogin = async (email: string, password: string) => {
|
||||
const resultAction = await dispatch(login({ email, password }));
|
||||
@ -51,7 +71,31 @@ export default function LoginPageClient() {
|
||||
dispatch(clearAuthMessage());
|
||||
};
|
||||
|
||||
if (isLoggedIn) {
|
||||
const handleChangePassword = async (passwordData: {
|
||||
currentPassword?: string;
|
||||
newPassword: string;
|
||||
}) => {
|
||||
if (!user?.email) {
|
||||
console.error("No user email available for password change");
|
||||
return false;
|
||||
}
|
||||
|
||||
const resultAction = await dispatch(
|
||||
changePassword({
|
||||
email: user.email,
|
||||
newPassword: passwordData.newPassword,
|
||||
currentPassword: passwordData.currentPassword,
|
||||
})
|
||||
);
|
||||
const result = changePassword.fulfilled.match(resultAction);
|
||||
|
||||
// if(result && resultAction.payload.success) {
|
||||
// setMustChangePassword(!resultAction.payload.success);
|
||||
// }
|
||||
return result;
|
||||
};
|
||||
|
||||
if (isLoggedIn && !mustChangePassword) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-container__content">
|
||||
@ -66,13 +110,23 @@ export default function LoginPageClient() {
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<Modal open={true} title="Login to Payment Cashier">
|
||||
<LoginModal
|
||||
onLogin={handleLogin}
|
||||
authMessage={authMessage}
|
||||
clearAuthMessage={handleClearAuthMessage}
|
||||
/>
|
||||
</Modal>
|
||||
{mustChangePassword ? (
|
||||
<Modal open={true} title="Change Your Temporary Password">
|
||||
<ChangePassword
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSubmit={handleChangePassword}
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
<Modal open={true} title="Login to Payment Cashier">
|
||||
<LoginModal
|
||||
onLogin={handleLogin}
|
||||
authMessage={authMessage}
|
||||
clearAuthMessage={handleClearAuthMessage}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@use "sass:color";
|
||||
|
||||
// Variables for consistent styling
|
||||
$primary-color: #2563eb; // Blue-600 equivalent
|
||||
$primary-hover-color: #1d4ed8; // Blue-700 equivalent
|
||||
@ -26,7 +28,8 @@ $bg-color-white: #ffffff;
|
||||
max-width: 56rem; // max-w-4xl
|
||||
background-color: $bg-color-white;
|
||||
border-radius: 0.75rem; // rounded-xl
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
|
||||
padding: 2rem; // p-8
|
||||
text-align: center;
|
||||
@ -59,6 +62,9 @@ $bg-color-white: #ffffff;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md
|
||||
transition: background-color 0.3s ease-in-out;
|
||||
&:hover {
|
||||
background-color: darken($error-color, 5%); // red-700 equivalent
|
||||
background-color: color.adjust(
|
||||
$error-color,
|
||||
$lightness: -5%
|
||||
); // red-700 equivalent
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,9 +24,12 @@ export default function ReduxProvider({
|
||||
}, 2000);
|
||||
|
||||
// Set up periodic token validation every 5 minutes
|
||||
intervalRef.current = setInterval(() => {
|
||||
store.dispatch(checkAuthStatus());
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
intervalRef.current = setInterval(
|
||||
() => {
|
||||
store.dispatch(checkAuthStatus());
|
||||
},
|
||||
5 * 60 * 1000
|
||||
); // 5 minutes
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { ThunkSuccess, ThunkError, IUserResponse } from "../types";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface TokenInfo {
|
||||
expiresAt: string;
|
||||
timeUntilExpiration: number;
|
||||
expiresInHours: number;
|
||||
}
|
||||
|
||||
// Define the initial state for the authentication slice
|
||||
interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
authMessage: string;
|
||||
status: "idle" | "loading" | "succeeded" | "failed";
|
||||
error: string | null;
|
||||
user: {
|
||||
email: string;
|
||||
role: string;
|
||||
} | null;
|
||||
tokenInfo: {
|
||||
expiresAt: string;
|
||||
timeUntilExpiration: number;
|
||||
expiresInHours: number;
|
||||
} | null;
|
||||
user: IUserResponse | null;
|
||||
tokenInfo: TokenInfo | null;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
@ -24,318 +25,295 @@ const initialState: AuthState = {
|
||||
error: null,
|
||||
user: null,
|
||||
tokenInfo: null,
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
// Async Thunk for Login
|
||||
// This handles the API call to your Next.js login Route Handler
|
||||
export const login = createAsyncThunk(
|
||||
// ---------------- Login ----------------
|
||||
export const login = createAsyncThunk<
|
||||
ThunkSuccess<{
|
||||
user: IUserResponse | null;
|
||||
tokenInfo: TokenInfo | null;
|
||||
mustChangePassword: boolean;
|
||||
}>,
|
||||
{ email: string; password: string },
|
||||
{ rejectValue: ThunkError }
|
||||
>(
|
||||
"auth/login",
|
||||
async (
|
||||
{ email, password }: { email: string; password: string },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// If the server responded with an error status (e.g., 401, 400, 500)
|
||||
return rejectWithValue(data.message || "Login failed");
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
toast.error(data.message || "Login failed");
|
||||
return rejectWithValue((data.message as string) || "Login failed");
|
||||
}
|
||||
|
||||
// On successful login, the backend sets the HTTP-only cookie.
|
||||
// We'll set a client-side flag (like localStorage) for immediate UI updates,
|
||||
// though the primary source of truth for auth is the HTTP-only cookie.
|
||||
if (typeof window !== "undefined") {
|
||||
// Ensure localStorage access is client-side
|
||||
localStorage.setItem("userToken", "mock-authenticated"); // For client-side state sync
|
||||
}
|
||||
// After login, validate session right away
|
||||
const validateRes = await fetch("/api/auth/validate", { method: "POST" });
|
||||
const validateData = await validateRes.json();
|
||||
|
||||
// After successful login, check auth status to get token information
|
||||
// This ensures we have the token expiration details immediately
|
||||
const authStatusResponse = await fetch("/api/auth/status");
|
||||
if (authStatusResponse.ok) {
|
||||
const authData = await authStatusResponse.json();
|
||||
return {
|
||||
message: data.message || "Login successful",
|
||||
user: authData.user,
|
||||
tokenInfo: authData.tokenInfo,
|
||||
};
|
||||
}
|
||||
toast.success(data.message || "Login successful");
|
||||
|
||||
return data.message || "Login successful";
|
||||
} catch (error: unknown) {
|
||||
// Handle network errors or other unexpected issues
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Network error during login";
|
||||
return rejectWithValue(errorMessage);
|
||||
return {
|
||||
message: data.message || "Login successful",
|
||||
user: data.user || null,
|
||||
tokenInfo: validateData.tokenInfo || null,
|
||||
mustChangePassword: data.must_change_password || false,
|
||||
} as ThunkSuccess<{
|
||||
user: IUserResponse | null;
|
||||
tokenInfo: TokenInfo | null;
|
||||
mustChangePassword: boolean;
|
||||
}>;
|
||||
} catch (err: unknown) {
|
||||
return rejectWithValue(
|
||||
(err as Error).message || "Network error during login"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Async Thunk for Logout
|
||||
// This handles the API call to your Next.js logout Route Handler
|
||||
export const logout = createAsyncThunk(
|
||||
"auth/logout",
|
||||
async (_, { rejectWithValue }) => {
|
||||
// ---------------- Logout ----------------
|
||||
export const logout = createAsyncThunk<
|
||||
ThunkSuccess,
|
||||
void,
|
||||
{ rejectValue: ThunkError }
|
||||
>("auth/logout", async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/logout", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok)
|
||||
return rejectWithValue((data.message as string) || "Logout failed");
|
||||
|
||||
// Persisted state will be cleared by reducers below; avoid direct persistor usage here.
|
||||
|
||||
return { message: data.message || "Logged out successfully" };
|
||||
} catch (err: unknown) {
|
||||
return rejectWithValue(
|
||||
(err as Error).message || "Network error during logout"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------- Auto Logout ----------------
|
||||
export const autoLogout = createAsyncThunk<
|
||||
ThunkSuccess,
|
||||
string,
|
||||
{ rejectValue: ThunkError }
|
||||
>("auth/autoLogout", async (reason: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "DELETE" });
|
||||
return { message: `Auto-logout: ${reason}` };
|
||||
} catch (err: unknown) {
|
||||
return rejectWithValue((err as Error).message || "Auto-logout failed");
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------- Change Password ----------------
|
||||
export const changePassword = createAsyncThunk<
|
||||
ThunkSuccess<{ success: boolean }>,
|
||||
{ email: string; newPassword: string; currentPassword?: string },
|
||||
{ rejectValue: ThunkError }
|
||||
>(
|
||||
"auth/changePassword",
|
||||
async ({ email, newPassword, currentPassword }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "DELETE",
|
||||
const res = await fetch("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, newPassword, currentPassword }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const data = await res.json();
|
||||
if (!res.ok)
|
||||
return rejectWithValue(
|
||||
(data.message as string) || "Password change failed"
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// If the server responded with an error status
|
||||
return rejectWithValue(data.message || "Logout failed");
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// Ensure localStorage access is client-side
|
||||
localStorage.removeItem("userToken"); // Clear client-side flag
|
||||
}
|
||||
|
||||
// After successful logout, check auth status to ensure proper cleanup
|
||||
const authStatusResponse = await fetch("/api/auth/status");
|
||||
if (authStatusResponse.ok) {
|
||||
const authData = await authStatusResponse.json();
|
||||
return {
|
||||
message: data.message || "Logged out successfully",
|
||||
isAuthenticated: authData.isAuthenticated,
|
||||
};
|
||||
}
|
||||
|
||||
return data.message || "Logged out successfully";
|
||||
} catch (error: unknown) {
|
||||
// Handle network errors
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Network error during logout";
|
||||
return rejectWithValue(errorMessage);
|
||||
return {
|
||||
success: data.success,
|
||||
message: data.message || "Password changed successfully",
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return rejectWithValue(
|
||||
(err as Error).message || "Network error during password change"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Async Thunk for automatic logout (when token expires)
|
||||
export const autoLogout = createAsyncThunk(
|
||||
"auth/autoLogout",
|
||||
async (reason: string, { rejectWithValue }) => {
|
||||
try {
|
||||
// Clear the cookie by calling logout endpoint
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "DELETE",
|
||||
});
|
||||
// ---------------- Unified Validate Auth ----------------
|
||||
export const validateAuth = createAsyncThunk<
|
||||
{ tokenInfo: TokenInfo | null },
|
||||
void,
|
||||
{ rejectValue: ThunkError }
|
||||
>("auth/validate", async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/validate", { method: "POST" });
|
||||
const data = await res.json();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// Clear client-side storage
|
||||
localStorage.removeItem("userToken");
|
||||
}
|
||||
|
||||
return { message: `Auto-logout: ${reason}` };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Auto-logout failed";
|
||||
return rejectWithValue(errorMessage);
|
||||
if (!res.ok || !data.valid) {
|
||||
return rejectWithValue("Session invalid or expired");
|
||||
}
|
||||
|
||||
return {
|
||||
tokenInfo: data.tokenInfo || null,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return rejectWithValue(
|
||||
(err as Error).message || "Network error during validation"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Async Thunk for manual refresh of authentication status
|
||||
export const refreshAuthStatus = createAsyncThunk(
|
||||
"auth/refreshStatus",
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/status");
|
||||
const data = await response.json();
|
||||
// TODO - Creaye a new thunk to update the user stuff
|
||||
|
||||
if (!response.ok) {
|
||||
return rejectWithValue(data.message || "Authentication refresh failed");
|
||||
}
|
||||
// ---------------- Update User Details ----------------
|
||||
export const updateUserDetails = createAsyncThunk<
|
||||
ThunkSuccess<{ user: IUserResponse }>,
|
||||
{ id: string; updates: Partial<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;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Network error during auth refresh";
|
||||
return rejectWithValue(errorMessage);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return rejectWithValue(data.message || "Failed to update user details");
|
||||
}
|
||||
|
||||
return {
|
||||
message: data.message || "User details updated successfully",
|
||||
user: data.user,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return rejectWithValue(
|
||||
(err as Error).message || "Network error during user update"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Async Thunk for checking authentication status
|
||||
export const checkAuthStatus = createAsyncThunk(
|
||||
"auth/checkStatus",
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/status");
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return rejectWithValue(data.message || "Authentication check failed");
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Network error during auth check";
|
||||
return rejectWithValue(errorMessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create the authentication slice
|
||||
// ---------------- Slice ----------------
|
||||
const authSlice = createSlice({
|
||||
name: "auth",
|
||||
initialState,
|
||||
reducers: {
|
||||
// Reducer to set an authentication message (e.g., from UI actions)
|
||||
setAuthMessage: (state, action: PayloadAction<string>) => {
|
||||
state.authMessage = action.payload;
|
||||
},
|
||||
// Reducer to clear the authentication message
|
||||
clearAuthMessage: (state) => {
|
||||
clearAuthMessage: state => {
|
||||
state.authMessage = "";
|
||||
},
|
||||
// Reducer to initialize login status from client-side storage (e.g., on app load)
|
||||
// This is useful for cases where middleware might not redirect immediately,
|
||||
// or for client-side rendering of protected content based on initial state.
|
||||
initializeAuth: (state) => {
|
||||
if (typeof window !== "undefined") {
|
||||
// Ensure this runs only on the client
|
||||
const userToken = localStorage.getItem("userToken");
|
||||
state.isLoggedIn = userToken === "mock-authenticated";
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// Login Thunk Reducers
|
||||
.addCase(login.pending, (state) => {
|
||||
// Login
|
||||
.addCase(login.pending, state => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
state.authMessage = "Attempting login...";
|
||||
})
|
||||
.addCase(login.fulfilled, (state, action) => {
|
||||
state.status = "succeeded";
|
||||
state.isLoggedIn = true;
|
||||
// Handle both old string payload and new object payload
|
||||
if (typeof action.payload === "string") {
|
||||
state.authMessage = action.payload;
|
||||
} else {
|
||||
state.authMessage = action.payload.message;
|
||||
state.user = action.payload.user || null;
|
||||
state.tokenInfo = action.payload.tokenInfo || null;
|
||||
}
|
||||
state.authMessage = action.payload.message;
|
||||
state.user = action.payload.user;
|
||||
state.tokenInfo = action.payload.tokenInfo;
|
||||
state.mustChangePassword = action.payload.mustChangePassword;
|
||||
})
|
||||
.addCase(login.rejected, (state, action) => {
|
||||
state.status = "failed";
|
||||
state.isLoggedIn = false;
|
||||
state.error = action.payload as string;
|
||||
state.authMessage = action.payload as string; // Display error message
|
||||
})
|
||||
// Logout Thunk Reducers
|
||||
.addCase(logout.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
state.authMessage = "Logging out...";
|
||||
state.authMessage = action.payload as string;
|
||||
})
|
||||
// Logout
|
||||
.addCase(logout.fulfilled, (state, action) => {
|
||||
state.status = "succeeded";
|
||||
state.isLoggedIn = false;
|
||||
// Handle both old string payload and new object payload
|
||||
if (typeof action.payload === "string") {
|
||||
state.authMessage = action.payload;
|
||||
} else {
|
||||
state.authMessage = action.payload.message;
|
||||
// Ensure we're properly logged out
|
||||
if (action.payload.isAuthenticated === false) {
|
||||
state.user = null;
|
||||
state.tokenInfo = null;
|
||||
}
|
||||
}
|
||||
// Always clear user data on logout
|
||||
state.authMessage = action.payload.message;
|
||||
state.user = null;
|
||||
state.tokenInfo = null;
|
||||
state.mustChangePassword = false;
|
||||
})
|
||||
.addCase(logout.rejected, (state, action) => {
|
||||
state.status = "failed";
|
||||
state.isLoggedIn = true; // Stay logged in if logout failed
|
||||
state.error = action.payload as string;
|
||||
state.authMessage = action.payload as string; // Display error message
|
||||
})
|
||||
// Check Auth Status Thunk Reducers
|
||||
.addCase(checkAuthStatus.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(checkAuthStatus.fulfilled, (state, action) => {
|
||||
state.status = "succeeded";
|
||||
state.isLoggedIn = action.payload.isAuthenticated;
|
||||
state.user = action.payload.user || null;
|
||||
state.tokenInfo = action.payload.tokenInfo || null;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(checkAuthStatus.rejected, (state, action) => {
|
||||
state.status = "failed";
|
||||
state.isLoggedIn = false;
|
||||
state.user = null;
|
||||
state.tokenInfo = null;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Auto Logout Thunk Reducers
|
||||
.addCase(autoLogout.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
state.authMessage = "Session expired, logging out...";
|
||||
state.authMessage = action.payload as string;
|
||||
})
|
||||
// Auto Logout
|
||||
.addCase(autoLogout.fulfilled, (state, action) => {
|
||||
state.status = "succeeded";
|
||||
state.isLoggedIn = false;
|
||||
state.authMessage = action.payload.message;
|
||||
state.user = null;
|
||||
state.tokenInfo = null;
|
||||
state.error = null;
|
||||
state.mustChangePassword = false;
|
||||
})
|
||||
.addCase(autoLogout.rejected, (state, action) => {
|
||||
state.status = "failed";
|
||||
state.isLoggedIn = false;
|
||||
state.user = null;
|
||||
state.tokenInfo = null;
|
||||
state.error = action.payload as string;
|
||||
state.authMessage = "Auto-logout failed";
|
||||
})
|
||||
// Refresh Auth Status Thunk Reducers
|
||||
.addCase(refreshAuthStatus.pending, (state) => {
|
||||
// Change Password
|
||||
.addCase(changePassword.pending, state => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
state.authMessage = "Changing password...";
|
||||
})
|
||||
.addCase(refreshAuthStatus.fulfilled, (state, action) => {
|
||||
.addCase(changePassword.fulfilled, (state, action) => {
|
||||
state.status = "succeeded";
|
||||
state.isLoggedIn = action.payload.isAuthenticated;
|
||||
state.user = action.payload.user || null;
|
||||
state.tokenInfo = action.payload.tokenInfo || null;
|
||||
state.error = null;
|
||||
state.authMessage = action.payload.message;
|
||||
state.mustChangePassword = action.payload.success;
|
||||
})
|
||||
.addCase(refreshAuthStatus.rejected, (state, action) => {
|
||||
.addCase(changePassword.rejected, (state, action) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload as string;
|
||||
state.authMessage = action.payload as string;
|
||||
})
|
||||
// Validate Auth
|
||||
.addCase(validateAuth.pending, state => {
|
||||
state.status = "loading";
|
||||
})
|
||||
.addCase(validateAuth.fulfilled, (state, action) => {
|
||||
state.status = "succeeded";
|
||||
state.isLoggedIn = true;
|
||||
state.tokenInfo = action.payload.tokenInfo;
|
||||
})
|
||||
.addCase(validateAuth.rejected, (state, action) => {
|
||||
state.status = "failed";
|
||||
state.isLoggedIn = false;
|
||||
state.user = null;
|
||||
state.tokenInfo = null;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update User Details
|
||||
.addCase(updateUserDetails.pending, state => {
|
||||
state.status = "loading";
|
||||
state.authMessage = "Updating user details...";
|
||||
})
|
||||
.addCase(updateUserDetails.fulfilled, (state, action) => {
|
||||
state.status = "succeeded";
|
||||
state.user = action.payload.user;
|
||||
state.authMessage = action.payload.message;
|
||||
})
|
||||
.addCase(updateUserDetails.rejected, (state, action) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload as string;
|
||||
state.authMessage = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setAuthMessage, clearAuthMessage, initializeAuth } =
|
||||
authSlice.actions;
|
||||
|
||||
export const { setAuthMessage, clearAuthMessage } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
|
||||
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 selectUser = (state: RootState) => state.auth?.user;
|
||||
export const selectTokenInfo = (state: RootState) => state.auth?.tokenInfo;
|
||||
export const selectMustChangePassword = (state: RootState) =>
|
||||
state.auth?.mustChangePassword;
|
||||
export const selectTimeUntilExpiration = (state: RootState) =>
|
||||
state.auth?.tokenInfo?.timeUntilExpiration || 0;
|
||||
export const selectExpiresInHours = (state: RootState) =>
|
||||
|
||||
@ -1,17 +1,70 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import {
|
||||
configureStore,
|
||||
combineReducers,
|
||||
type Reducer,
|
||||
} from "@reduxjs/toolkit";
|
||||
import storage from "redux-persist/lib/storage"; // defaults to localStorage for web
|
||||
import { createTransform, persistReducer, persistStore } from "redux-persist";
|
||||
import { createEpicMiddleware, combineEpics } from "redux-observable";
|
||||
import advancedSearchReducer from "./advanedSearch/advancedSearchSlice";
|
||||
import authReducer from "./auth/authSlice";
|
||||
import userEpics from "./user/epic";
|
||||
import authEpics from "./auth/epic";
|
||||
|
||||
export const makeStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
advancedSearch: advancedSearchReducer,
|
||||
auth: authReducer,
|
||||
},
|
||||
// Enable Redux DevTools
|
||||
devTools: process.env.NODE_ENV !== "production",
|
||||
});
|
||||
type PersistedAuth = { user: unknown | null };
|
||||
|
||||
const persistConfig = {
|
||||
key: "root",
|
||||
storage,
|
||||
whitelist: ["auth"], // only persist auth slice
|
||||
transforms: [
|
||||
// Persist only the `user` subkey from the auth slice
|
||||
createTransform(
|
||||
(inboundState: unknown): PersistedAuth => {
|
||||
const s = inboundState as { user?: unknown } | null | undefined;
|
||||
return { user: s?.user ?? null };
|
||||
},
|
||||
(outboundState: unknown): PersistedAuth => {
|
||||
const s = outboundState as { user?: unknown } | null | undefined;
|
||||
return { user: s?.user ?? null };
|
||||
},
|
||||
{ whitelist: ["auth"] }
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Create the store instance
|
||||
const rootReducer = combineReducers({
|
||||
advancedSearch: advancedSearchReducer,
|
||||
auth: authReducer,
|
||||
});
|
||||
|
||||
const rootEpic = combineEpics(...userEpics, ...authEpics);
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, rootReducer as Reducer);
|
||||
|
||||
// Create epic middleware
|
||||
const epicMiddleware = createEpicMiddleware();
|
||||
|
||||
export const makeStore = () => {
|
||||
const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
devTools: process.env.NODE_ENV !== "production",
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false, // redux-persist uses non-serializable values
|
||||
}).concat(epicMiddleware),
|
||||
});
|
||||
|
||||
// Run the epic
|
||||
epicMiddleware.run(rootEpic);
|
||||
|
||||
return store;
|
||||
};
|
||||
|
||||
// Create store + persistor
|
||||
export const store = makeStore();
|
||||
export const persistor = persistStore(store);
|
||||
|
||||
// Types
|
||||
export type RootState = ReturnType<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 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({
|
||||
query,
|
||||
}: {
|
||||
query: string;
|
||||
}) {
|
||||
export async function getApproves({ query }: { query: string }) {
|
||||
const res = await fetch(
|
||||
`http://localhost:3000/api/dashboard/approve?${query}`,
|
||||
`http://localhost:4000/api/dashboard/approve?${query}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
export async function getAudits({
|
||||
query,
|
||||
}: {
|
||||
query: string;
|
||||
}) {
|
||||
export async function getAudits({ query }: { query: string }) {
|
||||
const res = await fetch(
|
||||
`http://localhost:3000/api/dashboard/audits?${query}`,
|
||||
`http://localhost:4000/api/dashboard/audits?${query}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@ -5,12 +5,11 @@ export async function getTransactions({
|
||||
transactionType: string;
|
||||
query: string;
|
||||
}) {
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:3000/api/dashboard/transactions/${transactionType}?${query}`,
|
||||
`http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@ -1,17 +1,12 @@
|
||||
export async function getTransactionsHistory({
|
||||
query,
|
||||
}: {
|
||||
query: string;
|
||||
}) {
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BASE_URL}`
|
||||
: "http://localhost:3000";
|
||||
export async function getTransactionsHistory({ query }: { query: string }) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BASE_URL}`
|
||||
: "http://localhost:4000";
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/dashboard/transactionsHistory?${query}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { jwtVerify } from "jose";
|
||||
|
||||
// Secret key for JWT verification (must match the one used for signing)
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const rawSecret = (process.env.JWT_SECRET ?? "").trim().replace(/^"|"$/g, "");
|
||||
const JWT_SECRET = new TextEncoder().encode(rawSecret);
|
||||
|
||||
export interface JWTPayload {
|
||||
email: string;
|
||||
@ -15,7 +16,10 @@ export interface JWTPayload {
|
||||
*/
|
||||
export async function validateToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
const raw = token.startsWith("Bearer ") ? token.slice(7) : token;
|
||||
const { payload } = await jwtVerify(raw, JWT_SECRET, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
return payload as unknown as JWTPayload;
|
||||
} catch (error) {
|
||||
console.error("Token validation error:", error);
|
||||
@ -45,3 +49,19 @@ export function getTimeUntilExpiration(payload: JWTPayload): number {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return Math.max(0, payload.exp - currentTime);
|
||||
}
|
||||
|
||||
export function validatePassword(password: string) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) errors.push("At least 8 characters");
|
||||
if (!/[A-Z]/.test(password)) errors.push("One uppercase letter");
|
||||
if (!/[a-z]/.test(password)) errors.push("One lowercase letter");
|
||||
if (!/[0-9]/.test(password)) errors.push("One number");
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password))
|
||||
errors.push("One special character");
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,12 +8,14 @@ export const exportData = <TRow, TColumn extends GridColDef>(
|
||||
columns: TColumn[],
|
||||
fileType: FileType = "csv",
|
||||
onlyCurrentTable = false,
|
||||
setOpen: (open: boolean) => void,
|
||||
setOpen: (open: boolean) => void
|
||||
) => {
|
||||
const exportRows = onlyCurrentTable ? rows.slice(0, 5) : rows;
|
||||
const exportData = [
|
||||
columns.map((col) => col.headerName),
|
||||
...exportRows.map((row) => columns.map((col) => (row as Record<string, unknown>)[col.field] ?? "")),
|
||||
columns.map(col => col.headerName),
|
||||
...exportRows.map(row =>
|
||||
columns.map(col => (row as Record<string, unknown>)[col.field] ?? "")
|
||||
),
|
||||
];
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(exportData);
|
||||
|
||||
@ -2,12 +2,12 @@ export const formatToDateTimeString = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // months are 0-indexed
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0"); // months are 0-indexed
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,30 +1,81 @@
|
||||
// middleware.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const token = request.cookies.get("auth_token")?.value; // Get token from cookie
|
||||
const COOKIE_NAME = "auth_token";
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||
|
||||
// Define protected paths
|
||||
const protectedPaths = ["/dashboard", "/settings", "/admin"];
|
||||
const isProtected = protectedPaths.some((path) =>
|
||||
request.nextUrl.pathname.startsWith(path)
|
||||
);
|
||||
function isExpired(exp?: number) {
|
||||
return exp ? exp * 1000 <= Date.now() : false;
|
||||
}
|
||||
|
||||
// If accessing a protected path and no token
|
||||
if (isProtected && !token) {
|
||||
// Redirect to login page
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
// Optional: Add a redirect query param to return to original page after login
|
||||
loginUrl.searchParams.set("redirect", request.nextUrl.pathname);
|
||||
async function validateToken(token: string) {
|
||||
const raw = token.startsWith("Bearer ") ? token.slice(7) : token;
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(raw, JWT_SECRET, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
|
||||
return payload as {
|
||||
exp?: number;
|
||||
MustChangePassword?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Token validation error:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const token = request.cookies.get(COOKIE_NAME)?.value;
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
const currentPath = request.nextUrl.pathname;
|
||||
|
||||
// 1️⃣ No token
|
||||
if (!token) {
|
||||
loginUrl.searchParams.set("reason", "no-token");
|
||||
loginUrl.searchParams.set("redirect", currentPath);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// Allow the request to proceed if not protected or token exists
|
||||
// 2️⃣ Validate + decode
|
||||
const payload = await validateToken(token);
|
||||
|
||||
if (!payload) {
|
||||
const res = NextResponse.redirect(loginUrl);
|
||||
res.cookies.delete(COOKIE_NAME);
|
||||
loginUrl.searchParams.set("reason", "invalid-token");
|
||||
loginUrl.searchParams.set("redirect", currentPath);
|
||||
return res;
|
||||
}
|
||||
|
||||
// 3️⃣ Expiry check
|
||||
if (isExpired(payload.exp)) {
|
||||
const res = NextResponse.redirect(loginUrl);
|
||||
res.cookies.delete(COOKIE_NAME);
|
||||
loginUrl.searchParams.set("reason", "expired-token");
|
||||
loginUrl.searchParams.set("redirect", currentPath);
|
||||
return res;
|
||||
}
|
||||
|
||||
// 4️⃣ Must change password check
|
||||
if (payload.MustChangePassword) {
|
||||
loginUrl.searchParams.set("reason", "change-password");
|
||||
loginUrl.searchParams.set("redirect", currentPath);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// ✅ All good
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Configure matcher to run middleware on specific paths
|
||||
export const config = {
|
||||
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*"], // Apply to dashboard and its sub-paths
|
||||
matcher: [
|
||||
"/dashboard/:path*",
|
||||
"/settings/:path*",
|
||||
"/admin/:path*",
|
||||
"/change-password/:path*",
|
||||
],
|
||||
};
|
||||
|
||||
@ -86,19 +86,19 @@ export const handlers = [
|
||||
|
||||
if (userId) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.user.toString() === userId
|
||||
tx => tx.user.toString() === userId
|
||||
);
|
||||
}
|
||||
|
||||
if (state) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.state.toLowerCase() === state.toLowerCase()
|
||||
tx => tx.state.toLowerCase() === state.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
if (statusCode) {
|
||||
filteredTransactions = filteredTransactions.filter(
|
||||
(tx) => tx.pspStatusCode.toString() === statusCode
|
||||
tx => tx.pspStatusCode.toString() === statusCode
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// mocks/server.ts
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
@ -2,7 +2,11 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
webpack: (config) => {
|
||||
sassOptions: {
|
||||
quietDeps: true,
|
||||
silenceDeprecations: ["legacy-js-api"],
|
||||
},
|
||||
webpack: config => {
|
||||
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||
config.resolve.alias["@mswjs/interceptors"] = false;
|
||||
}
|
||||
|
||||
19
package.json
19
package.json
@ -4,10 +4,19 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"msw-init": "msw init public/ --save",
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "cross-env SASS_SILENCE_DEPRECATION_WARNINGS=1 next dev --turbopack -p 4000",
|
||||
"dev:debug": "node --inspect-brk ./node_modules/next/dist/bin/next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"format:staged": "prettier --write",
|
||||
"type-check": "tsc --noEmit",
|
||||
"check-all": "npm run type-check && npm run lint && npm run format:check",
|
||||
"fix-all": "npm run lint:fix && npm run format",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@ -26,8 +35,11 @@
|
||||
"react": "^19.0.0",
|
||||
"react-date-range": "^2.0.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"recharts": "^2.15.3",
|
||||
"redux-observable": "^3.0.0-rc.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.89.2",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
@ -39,9 +51,12 @@
|
||||
"@types/react-date-range": "^1.4.10",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"@types/redux-persist": "^4.3.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.3",
|
||||
"husky": "^9.1.7",
|
||||
"msw": "^2.10.2",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"msw": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { IEditUserForm } from "@/app/features/UserRoles/User.interfaces";
|
||||
|
||||
export async function addUser(data: IEditUserForm) {
|
||||
const res = await fetch("/api/dashboard/admin/users", {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
|
||||
49
yarn.lock
49
yarn.lock
@ -1036,6 +1036,13 @@
|
||||
dependencies:
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/redux-persist@^4.3.1":
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/redux-persist/-/redux-persist-4.3.1.tgz#aa4c876859e0bea5155e5f7980e5b8c4699dc2e6"
|
||||
integrity sha512-YkMnMUk+4//wPtiSTMfsxST/F9Gh9sPWX0LVxHuOidGjojHtMdpep2cYvQgfiDMnj34orXyZI+QJCQMZDlafKA==
|
||||
dependencies:
|
||||
redux-persist "*"
|
||||
|
||||
"@types/statuses@^2.0.4":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz"
|
||||
@ -2401,6 +2408,11 @@ globalthis@^1.0.4:
|
||||
define-properties "^1.2.1"
|
||||
gopd "^1.0.1"
|
||||
|
||||
goober@^2.1.16:
|
||||
version "2.1.18"
|
||||
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.18.tgz#b72d669bd24d552d441638eee26dfd5716ea6442"
|
||||
integrity sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==
|
||||
|
||||
gopd@^1.0.1, gopd@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
|
||||
@ -2471,6 +2483,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1:
|
||||
dependencies:
|
||||
react-is "^16.7.0"
|
||||
|
||||
husky@^9.1.7:
|
||||
version "9.1.7"
|
||||
resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d"
|
||||
integrity sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==
|
||||
|
||||
ignore@^5.2.0:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
|
||||
@ -3160,6 +3177,11 @@ prelude-ls@^1.2.1:
|
||||
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
|
||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||
|
||||
prettier@^3.6.2:
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393"
|
||||
integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==
|
||||
|
||||
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
|
||||
@ -3208,6 +3230,14 @@ react-dom@^19.0.0:
|
||||
dependencies:
|
||||
scheduler "^0.26.0"
|
||||
|
||||
react-hot-toast@^2.6.0:
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.6.0.tgz#4ada6ed3c75c5e42a90d562f55665ff37ee1442b"
|
||||
integrity sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==
|
||||
dependencies:
|
||||
csstype "^3.1.3"
|
||||
goober "^2.1.16"
|
||||
|
||||
react-is@^16.13.1, react-is@^16.7.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
||||
@ -3286,6 +3316,16 @@ recharts@^2.15.3:
|
||||
tiny-invariant "^1.3.1"
|
||||
victory-vendor "^36.6.8"
|
||||
|
||||
redux-observable@^3.0.0-rc.2:
|
||||
version "3.0.0-rc.2"
|
||||
resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-3.0.0-rc.2.tgz#baef603781c5dabd9ddd70526357076cd5c128a2"
|
||||
integrity sha512-gG/pWIKgSrcTyyavm2so5tc7tuyCQ47p3VdCAG6wt+CV0WGhDr50cMQHLcYKxFZSGgTm19a8ZmyfJGndmGDpYg==
|
||||
|
||||
redux-persist@*:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8"
|
||||
integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==
|
||||
|
||||
redux-thunk@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz"
|
||||
@ -3384,6 +3424,13 @@ run-parallel@^1.1.9:
|
||||
dependencies:
|
||||
queue-microtask "^1.2.2"
|
||||
|
||||
rxjs@^7.8.2:
|
||||
version "7.8.2"
|
||||
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b"
|
||||
integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
|
||||
dependencies:
|
||||
tslib "^2.1.0"
|
||||
|
||||
safe-array-concat@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz"
|
||||
@ -3777,7 +3824,7 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@^2.4.0, tslib@^2.8.0:
|
||||
tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user