Initial Commit

This commit is contained in:
DeathKaioken 2025-09-07 12:44:54 +02:00
commit b3acaef775
196 changed files with 128541 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
public/css/output.css
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsprojn
*.sln
*.sw?
# Already present entries...
node_modules
dist
build
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
coverage
*.log
.vscode/
.idea/
.DS_Store
public/css/output.css

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# RegisterFrontend
This is a modern React + Vite + Tailwind CSS frontend for a registration and dashboard system. It features authentication, user onboarding, admin management, and responsive UI components, structured for scalability and maintainability.
---
## 📁 Project Structure
- **src/features/**
Feature-based folders (e.g., `login`, `dashboard`, `admin`) each with their own `components/`, `pages/`, and (optionally) `hooks/`, `api/`, and `__tests__/`.
- **src/shared/**
Reusable components, hooks, utilities, and store logic shared across features.
- **src/auth/AuthWrapper.jsx**
Handles authentication, token refresh, and protected route logic.
- **src/store/**
Zustand stores for global state (e.g., authentication).
- **src/pages/**
Top-level route pages (may be migrated into features as the project evolves).
- **tailwind.config.js**
Tailwind CSS configuration.
- **vite.config.js**
Vite build and dev server configuration.
---
## 🚀 Getting Started (Local Development)
### 1. **Clone the Repository**
```bash
git clone <your-repo-url>
cd REGISTERFRONTEND
```
### 2. **Install Dependencies**
```bash
npm install
# or
yarn install
```
### 3. **Environment Variables**
Create a `.env` file in the root directory. Example:
```
VITE_API_BASE_URL=http://localhost:3001
VITE_CENTRAL_SERVER_ENDPOINT=http://localhost:3001
```
Adjust these URLs to match your backend API endpoints.
### 4. **Run the Development Server**
```bash
npm run dev
# or
yarn dev
```
The app will be available at [http://localhost:5173](http://localhost:5173) (or the port shown in your terminal).
---
## 🛠️ Key Features
- **Authentication:**
Login, token refresh, and protected routes via `AuthWrapper`.
- **Feature-based Structure:**
Each major feature (login, dashboard, admin, etc.) is isolated for scalability.
- **Responsive Design:**
Built with Tailwind CSS and responsive utility classes.
- **State Management:**
Uses Zustand for global state (auth, user).
- **API Integration:**
Fetches data from a backend API, with credentials support for secure cookies.
- **Referral Link Generation:**
Referral links are generated without specifying a registration type. The type (personal/company) is now selected by the user during the registration process, not during referral link creation.
- **Registration Flow:**
During registration, users select their registration type (personal or company) via a toggle/slider. This selection is sent to the backend as part of the registration request.
---
## 🧑‍💻 Development Notes
- **Mobile Support:**
The app is being refactored for improved mobile responsiveness. Test your changes on various screen sizes.
- **Token Handling:**
Access tokens are stored in Zustand and sessionStorage; refresh logic is handled in `AuthWrapper.jsx`.
- **Custom Hooks & API Utilities:**
As the project grows, move repeated logic into `hooks/` and `api/` folders within each feature.
---
## 🤝 Contributing
1. Fork the repo and create a feature branch.
2. Follow the existing folder structure and naming conventions.
3. Test your changes locally before submitting a pull request.
---
## 📄 License
This project is for internal use. Please contact the maintainer for licensing details.
---

29
eslint.config.js Normal file
View File

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/img/profit_planet_favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Profit-Planet</title>
<link rel="stylesheet" href="/css/index.css">
<link rel="stylesheet" href="/css/fallback/register.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

7977
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "registerhub",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"build-css": "npx @tailwindcss/cli -i ./public/css/index.css -o ./public/css/output.css"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@lottiefiles/react-lottie-player": "^3.6.0",
"@react-pdf/renderer": "^4.3.0",
"@tailwindcss/postcss": "^4.1.12",
"autoprefixer": "^10.4.21",
"axios": "^1.10.0",
"country-flag-icons": "^1.5.19",
"country-select-js": "^2.1.0",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"intl-tel-input": "^25.5.2",
"pdfjs-dist": "^5.4.54",
"postcss": "^8.5.6",
"postcss-preset-env": "^10.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.5.2",
"react-i18next": "^15.6.1",
"react-pdf": "^10.1.0",
"react-phone-number-input": "^3.4.12",
"react-router-dom": "^6.23.1",
"react-toastify": "^11.0.5",
"winston": "^3.17.0",
"yup": "^1.7.0",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@tailwindcss/cli": "^4.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"tailwindcss": "^4.1.12",
"vite": "^7.0.4"
}
}

13
postcss.config.mjs Normal file
View File

@ -0,0 +1,13 @@
// filepath: c:\Users\alexa\Desktop\CODE\COMPLETE REDO\REGISTERFRONTEND\postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
"postcss-preset-env": {
stage: 3,
features: {
"nesting-rules": true,
}
}
}
};

View File

@ -0,0 +1,179 @@
/* Basic button fallbacks (older browsers / when Tailwind v4 utilities fail) */
.fallback-btn {
display: inline-block;
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
line-height: 1.2;
font-weight: 500;
border-radius: 0.5rem;
border: none;
cursor: pointer;
text-align: center;
text-decoration: none;
background: #555;
color: #fff;
box-sizing: border-box;
}
.fallback-btn--primary {
background: #1d4ed8; /* blue-700 */
}
.fallback-btn--primary:hover,
.fallback-btn--primary:focus {
background: #1e40af; /* blue-800 */
}
.fallback-btn--secondary {
background: #1f2937; /* gray-800 */
}
.fallback-btn--secondary:hover,
.fallback-btn--secondary:focus {
background: #111827; /* gray-900 */
}
.fallback-btn:disabled,
.fallback-btn[aria-disabled="true"] {
opacity: 0.7;
cursor: not-allowed;
}
.fallback-btn--auto {
width: 100%;
}
@media (min-width: 640px) {
.fallback-btn--auto {
width: auto;
}
}
/* Spinner alignment fallback */
.fallback-btn svg {
vertical-align: middle;
}
/* --- Enhanced slider (mode toggle) fallbacks --- */
.register-mode-toggle {
display: flex;
border: 1px solid #1d4ed8;
border-radius: 0.75rem;
overflow: hidden;
background: #f9fafb;
position: relative;
max-width: 320px;
margin: 0 auto 1.25rem auto;
}
.register-mode-toggle button {
flex: 1 1 0;
background: #e5e7eb !important; /* slate-200 */
color: #1e3a8a !important; /* blue-800 */
padding: 0.75rem 1rem;
font-weight: 700;
font-size: 1.05rem;
font-family: inherit;
border: none;
outline: none;
box-shadow: none;
cursor: pointer;
position: relative;
opacity: 1 !important;
border-right: 1px solid #1d4ed8;
/* --- Smooth transition for background, color, box-shadow, border --- */
transition:
background 0.35s cubic-bezier(.4,0,.2,1),
color 0.35s cubic-bezier(.4,0,.2,1),
box-shadow 0.35s cubic-bezier(.4,0,.2,1),
border 0.35s cubic-bezier(.4,0,.2,1);
}
.register-mode-toggle button:last-child {
border-right: none;
}
.register-mode-toggle button.active,
.register-mode-toggle button[aria-selected="true"] {
background: #1d4ed8 !important; /* blue-700 */
color: #fff !important;
/* Add a subtle shadow for depth */
box-shadow: 0 2px 12px 0 rgba(30,64,175,0.08);
}
.register-mode-toggle button.active::after,
.register-mode-toggle button[aria-selected="true"]::after {
content: "";
position: absolute;
inset: 0;
border: 2px solid #1d4ed8;
border-radius: 0; /* <-- changed from 0.75rem to 0 */
pointer-events: none;
/* Animate border appearance */
transition: border 0.35s cubic-bezier(.4,0,.2,1);
}
/* Provide a minimal focus style for accessibility */
.register-mode-toggle button:focus-visible {
box-shadow: 0 0 0 3px #bfdbfe !important;
}
@media (hover:hover) {
.register-mode-toggle button:hover:not(.active) {
background: #cbd5e1 !important; /* slate-300 */
color: #1e3a8a !important;
/* Slight shadow on hover for feedback */
box-shadow: 0 1px 6px 0 rgba(30,64,175,0.06);
}
}
/* Graceful degradation for very old browsers */
@supports not (gap: 1rem) {
.register-mode-toggle button {
margin-right: 1px;
}
.register-mode-toggle button:last-child {
margin-right: 0;
}
}
/* High contrast / prefers-reduced-transparency environments */
@media (prefers-contrast: more) {
.register-mode-toggle button.active {
background: #003caa !important;
}
}
/* When no blur support we already ensure readable background elsewhere */
/* --- Safari-specific fallback for buttons and toggle --- */
@media not all and (min-resolution:.001dpcm) {
@supports (-webkit-touch-callout: none) {
.fallback-btn,
.register-mode-toggle button {
/* Safari sometimes ignores border-radius on buttons */
border-radius: 8px !important;
-webkit-border-radius: 8px !important;
/* Safari ignores some box-shadow, so add fallback */
box-shadow: 0 2px 8px 0 rgba(30,64,175,0.08);
-webkit-box-shadow: 0 2px 8px 0 rgba(30,64,175,0.08);
}
.register-mode-toggle {
/* Safari fallback for flex gap */
gap: 0 !important;
}
}
}
/* --- Legacy browser fallback for border-radius and appearance --- */
.fallback-btn,
.register-mode-toggle button {
border-radius: 0.5rem;
/* Fallback for very old browsers */
-webkit-border-radius: 0.5rem;
-moz-border-radius: 0.5rem;
/* Remove default button appearance */
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,53 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="2000.000000pt" height="212.000000pt" viewBox="0 0 2000.000000 212.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,212.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M4556 2090 c-401 -63 -639 -292 -731 -705 -22 -95 -31 -395 -16 -507
19 -142 49 -247 106 -364 105 -218 285 -361 531 -424 189 -49 458 -49 649 0
102 25 243 95 320 157 162 132 267 341 306 611 15 111 6 441 -16 537 -84 372
-276 584 -610 671 -87 23 -124 27 -290 30 -104 1 -217 -1 -249 -6z m329 -444
c99 -26 162 -78 209 -174 103 -206 96 -639 -14 -812 -41 -64 -76 -95 -145
-127 -53 -24 -72 -28 -160 -28 -127 0 -186 19 -258 85 -89 81 -131 190 -147
383 -10 122 1 287 25 382 42 161 133 264 262 294 58 14 172 12 228 -3z"/>
<path d="M13464 1089 c-214 -546 -391 -996 -392 -1001 -2 -5 28 -8 69 -6 l73
3 136 345 135 345 421 3 421 2 134 -350 134 -350 73 0 c68 0 73 2 67 19 -3 10
-174 459 -379 997 l-374 979 -64 3 -65 3 -389 -992z m477 754 c18 -55 309
-852 334 -915 7 -17 -15 -18 -364 -18 -204 0 -371 2 -371 5 0 3 64 171 141
373 116 301 175 461 224 610 8 22 13 13 36 -55z"/>
<path d="M220 1076 l0 -996 270 0 270 0 0 330 0 330 118 0 c421 0 672 157 758
475 25 92 30 297 9 389 -50 227 -199 373 -445 437 -80 21 -110 23 -532 26
l-448 4 0 -995z m750 553 c104 -24 154 -101 148 -229 -3 -68 -7 -84 -33 -121
-47 -67 -107 -93 -227 -97 l-98 -4 0 231 0 231 83 0 c45 0 102 -5 127 -11z"/>
<path d="M1980 1075 l0 -995 270 0 270 0 0 360 0 360 69 0 69 0 209 -357 209
-358 306 -3 306 -2 -272 407 c-150 225 -280 420 -289 434 l-17 25 70 48 c120
82 199 186 241 316 27 85 30 247 6 339 -62 232 -257 370 -580 411 -45 5 -258
10 -474 10 l-393 0 0 -995z m786 570 c54 -16 106 -62 123 -108 6 -16 11 -54
11 -86 0 -161 -80 -230 -277 -239 l-103 -5 0 227 0 226 98 0 c58 0 119 -6 148
-15z"/>
<path d="M6090 1075 l0 -995 265 0 265 0 2 373 3 372 298 3 297 2 0 215 0 215
-300 0 -300 0 0 190 0 190 325 0 325 0 0 215 0 215 -590 0 -590 0 0 -995z"/>
<path d="M7600 1075 l0 -995 270 0 270 0 0 995 0 995 -270 0 -270 0 0 -995z"/>
<path d="M8430 1850 l0 -220 240 0 240 0 0 -775 0 -775 270 0 270 0 0 775 0
775 243 2 242 3 3 218 2 217 -755 0 -755 0 0 -220z"/>
<path d="M10290 1075 l0 -995 70 0 70 0 0 409 0 408 243 6 c163 4 265 11 312
21 274 62 422 188 480 411 21 79 21 270 1 347 -45 169 -149 279 -323 339 -111
38 -229 49 -550 49 l-303 0 0 -995z m710 850 c237 -55 340 -184 340 -425 0
-239 -101 -373 -332 -441 -64 -19 -104 -22 -325 -26 l-253 -5 0 462 0 462 248
-4 c187 -4 265 -9 322 -23z"/>
<path d="M11920 1075 l0 -995 545 0 545 0 0 65 0 65 -475 0 -475 0 0 930 0
930 -70 0 -70 0 0 -995z"/>
<path d="M15030 1075 l0 -995 66 0 66 0 -4 880 c-1 483 0 876 3 872 3 -4 269
-400 590 -879 l583 -873 73 0 73 0 0 995 0 995 -71 0 -71 0 3 -874 c2 -481 2
-873 -1 -871 -3 2 -267 395 -585 874 l-580 870 -72 1 -73 0 0 -995z"/>
<path d="M17040 1075 l0 -995 545 0 545 0 0 65 0 65 -475 0 -475 0 0 425 0
425 445 0 445 0 0 65 0 65 -445 0 -445 0 0 380 0 380 475 0 475 0 0 60 0 60
-545 0 -545 0 0 -995z"/>
<path d="M18310 2010 l0 -60 325 0 325 0 0 -935 0 -935 70 0 70 0 2 933 3 932
323 3 322 2 0 60 0 60 -720 0 -720 0 0 -60z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

15
public/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your App Title</title>
<link rel="stylesheet" href="/css/fallback/register.css" />
<!-- Add other head elements here -->
</head>
<body>
<!-- Your app's root element -->
<div id="root"></div>
<!-- Add your scripts here -->
</body>
</html>

29546
public/pdf/pdf.mjs Normal file

File diff suppressed because it is too large Load Diff

1
public/pdf/pdf.mjs.map Normal file

File diff suppressed because one or more lines are too long

4244
public/pdf/pdf.sandbox.mjs Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

64492
public/pdf/pdf.worker.mjs Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

42
src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

36
src/App.jsx Normal file
View File

@ -0,0 +1,36 @@
import GlobalAnimatedBackground from './background/GlobalAnimatedBackground'
import AppRouter from './AppRouter'
import ToastComponent from './features/toast/ToastComponent'
import { useEffect } from 'react'
import { log } from './utils/logger' // import logger
function App() {
// Added debug env dump
useEffect(() => {
try {
const env = import.meta?.env || {}
log('[App] import.meta.env keys:', Object.keys(env))
log('[App] VITE_API_BASE_URL:', env.VITE_API_BASE_URL)
log('[App] globalThis.VITE_API_BASE_URL:', globalThis.VITE_API_BASE_URL)
log('[App] globalThis.__ENV__:', globalThis.__ENV__)
const meta = document.querySelector('meta[name="vite-api-base-url"]')?.getAttribute('content')
if (meta) log('[App] meta[vite-api-base-url]:', meta)
if (!env.VITE_API_BASE_URL) {
log('[App] WARNING: VITE_API_BASE_URL is undefined! API calls may fail.')
}
} catch (e) {
log('[App] env debug failed', e)
}
}, [])
return (
<div className="relative min-h-screen">
<GlobalAnimatedBackground />
<ToastComponent /> {/* Only here, outside AuthWrapper */}
<div className="relative z-10">
<AppRouter />
</div>
</div>
)
}
export default App

405
src/AppRouter.jsx Normal file
View File

@ -0,0 +1,405 @@
import React from "react";
import { Routes, Route } from "react-router-dom";
import Register from './features/register/pages/Register.jsx';
import Login from './features/login/pages/Login.jsx';
import Dashboard from './features/dashboard/pages/Dashboard.jsx';
import UploadID from "./features/quickactions/uploadID/pages/UploadID.jsx";
import VerifyEmail from "./features/quickactions/verifyEmail/pages/VerifyEmail.jsx";
import PersonalCompleteProfile from "./features/quickactions/completeProfile/pages/PersonalCompleteProfile.jsx";
import CompanyCompleteProfile from "./features/quickactions/completeProfile/pages/CompanyCompleteProfile.jsx";
import SignContract from "./features/quickactions/contracts/pages/SignContract.jsx";
import ReferralManagement from "./features/referralManagement/pages/ReferralManagement.jsx";
import UserManagement from "./features/admin/userManagement/pages/UserManagement.jsx";
import AdminDashboard from "./features/admin/adminDashboard/pages/AdminDashboard.jsx";
import CompanyAuthMiddleware from './auth/CompanyAuthMiddleware.jsx';
import PersonalAuthMiddleware from './auth/PersonalAuthMiddleware.jsx';
import AdminAuthMiddleware from './auth/AdminAuthMiddleware.jsx';
import PermissionManagement from "./features/admin/permissionManagement/pages/PermissionManagement.jsx";
import useAuthStore from "./store/authStore";
import { showToast } from "./features/toast/toastUtils.js";
import { useNavigate } from "react-router-dom";
import AdminUserListView from "./features/admin/userManagement/components/AdminUserListView.jsx";
import UserProfilePage from "./features/profile/pages/UserProfilePage.jsx";
import VerifyUserQueue from "./features/admin/verifyUser/pages/VerifyUserQueue.jsx";
import NotFoundPage from "./features/error/pages/404.jsx";
import VerifyUser from "./features/admin/verifyUser/pages/VerifyUser.jsx";
import AdminUserProfilePage from "./features/admin/userManagement/pages/AdminUserProfilePage.jsx";
import PasswordReset from './features/password-reset/pages/PasswordReset.jsx';
import PasswordResetSetNewPassword from './features/password-reset/pages/PasswordResetSetNewPassword.jsx';
import ContractDashboard from "./features/admin/contractDashboard/pages/contractDashboard.jsx";
import { log } from "./utils/logger";
import AuthWrapper from './auth/AuthWrapper'
function AppRouter() {
return (
<Routes>
{/* Public Routes */}
<Route path="/" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/password-reset" element={<PasswordReset />} />
<Route path="/password-reset-set" element={<PasswordResetSetNewPassword />} />
{/* Protected Routes */}
<Route
path="/dashboard"
element={
<AuthWrapper>
<Dashboard />
</AuthWrapper>
}
/>
{/* Personal Routes */}
<Route
path="/personal-verify-email"
element={
<AuthWrapper>
<PersonalAuthMiddleware>
<VerifyEmail />
</PersonalAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/personal-id-upload"
element={
<AuthWrapper>
<PersonalAuthMiddleware>
<UploadID />
</PersonalAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/personal-complete-profile"
element={
<AuthWrapper>
<PersonalAuthMiddleware>
<PersonalCompleteProfile />
</PersonalAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/personal-sign-contract"
element={
<AuthWrapper>
<PersonalAuthMiddleware>
<SignContractGuard>
<SignContract />
</SignContractGuard>
</PersonalAuthMiddleware>
</AuthWrapper>
}
/>
{/* Company Routes */}
<Route
path="/company-verify-email"
element={
<AuthWrapper>
<CompanyAuthMiddleware>
<VerifyEmail />
</CompanyAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/company-id-upload"
element={
<AuthWrapper>
<CompanyAuthMiddleware>
<UploadID />
</CompanyAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/company-complete-profile"
element={
<AuthWrapper>
<CompanyAuthMiddleware>
<CompanyCompleteProfile />
</CompanyAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/company-sign-contract"
element={
<AuthWrapper>
<CompanyAuthMiddleware>
<SignContractGuard>
<SignContract />
</SignContractGuard>
</CompanyAuthMiddleware>
</AuthWrapper>
}
/>
{/* Admin Routes */}
<Route
path="/admin/dashboard"
element={
<AuthWrapper>
<AdminAuthMiddleware>
<AdminDashboard />
</AdminAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/admin/user-management"
element={
<AuthWrapper>
<AdminAuthMiddleware>
<UserManagement />
</AdminAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/admin/permission-management"
element={
<AuthWrapper>
<AdminAuthMiddleware>
<PermissionManagement />
</AdminAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/admin/verify-users"
element={
<AuthWrapper>
<AdminAuthMiddleware>
<VerifyUserQueue />
</AdminAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/admin/user-management/view/:id"
element={
<AuthWrapper>
<AdminAuthMiddleware>
<AdminUserListView />
</AdminAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/admin/user-management/verify/:id"
element={
<AuthWrapper>
<AdminAuthMiddleware>
<VerifyUser />
</AdminAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/admin/user-management/profile/:id"
element={
<AuthWrapper>
<AdminAuthMiddleware>
<AdminUserProfilePage />
</AdminAuthMiddleware>
</AuthWrapper>
}
/>
<Route
path="/admin/contract-dashboard"
element={
<AuthWrapper>
<AdminAuthMiddleware>
<ContractDashboard />
</AdminAuthMiddleware>
</AuthWrapper>
}
/>
{/* User Routes */}
<Route
path="/profile"
element={
<AuthWrapper>
<ProfileRedirect />
</AuthWrapper>
}
/>
<Route
path="/personal/profile"
element={
<AuthWrapper>
<UserProfilePage />
</AuthWrapper>
}
/>
<Route
path="/company/profile"
element={
<AuthWrapper>
<UserProfilePage />
</AuthWrapper>
}
/>
{/* Referral Management Route - Permission Protected */}
<Route
path="/referral-management"
element={
<AuthWrapper>
<ReferralManagementProtected />
</AuthWrapper>
}
/>
{/* 404 Catch-all Route */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
export default AppRouter;
// Profile redirect logic
function ProfileRedirect() {
const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
React.useEffect(() => {
if (user?.userType === "company") {
navigate("/company/profile", { replace: true });
} else {
navigate("/personal/profile", { replace: true });
}
}, [user, navigate]);
return null;
}
// Permission-protected wrapper for ReferralManagement
function ReferralManagementProtected() {
const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
React.useEffect(() => {
const hasPermission =
user &&
(
user.role === "admin" ||
user.role === "super_admin" ||
(Array.isArray(user.permissions) && user.permissions.includes("can_create_referrals"))
);
if (!hasPermission) {
showToast({ type: "error", message: "You do not have permission to access Referral Management." });
navigate("/dashboard", { replace: true });
}
}, [user, navigate]);
// Only render if user is allowed
const hasPermission =
user &&
(
user.role === "admin" ||
user.role === "super_admin" ||
(Array.isArray(user.permissions) && user.permissions.includes("can_create_referrals"))
);
return hasPermission ? <ReferralManagement /> : null;
}
// Route guard for sign contract
function SignContractGuard({ children }) {
const user = useAuthStore((s) => s.user);
const accessToken = useAuthStore((s) => s.accessToken);
const [userStatus, setUserStatus] = React.useState(null);
const navigate = useNavigate();
React.useEffect(() => {
async function checkStatus() {
log("[SignContractGuard] Checking contract eligibility...");
log("[SignContractGuard] user:", user);
log("[SignContractGuard] accessToken:", accessToken ? accessToken.substring(0, 20) + "..." : null);
try {
const url = `${import.meta.env.VITE_API_BASE_URL}/api/auth/user/status-progress`;
log("[SignContractGuard] Fetching status from:", url);
const res = await fetch(
url,
{
method: "GET",
credentials: "include",
headers: {
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
"Content-Type": "application/json"
}
}
);
log("[SignContractGuard] Response status:", res.status);
if (!res.ok) throw new Error("Failed to fetch user status");
const data = await res.json();
log("[SignContractGuard] Response data:", data);
// Accept both old and new API formats
let eligible = false;
if (data.status) {
// Old format: status fields
eligible =
data.status.email_verified === 1 &&
data.status.documents_uploaded === 1 &&
data.status.profile_completed === 1;
} else if (data.progress) {
// New format: progress steps array
const steps = data.progress.steps || [];
const required = ["email_verified", "profile_completed", "documents_uploaded"];
eligible = required.every(
key =>
steps.find(s => s.key === key && (s.completed === true || s.completed === 1))
);
}
setUserStatus(data.status || data.progress);
if (!eligible) {
log("[SignContractGuard] Missing steps. Blocking access.");
showToast({
type: "error",
message: "Please complete email verification, ID upload, and profile completion before signing the contract."
});
navigate("/dashboard", { replace: true });
} else {
log("[SignContractGuard] All steps completed. Allowing access.");
}
} catch (err) {
log("[SignContractGuard] Error fetching status:", err);
showToast({ type: "error", message: "Unable to check contract eligibility." });
navigate("/dashboard", { replace: true });
}
}
checkStatus();
}, [navigate, accessToken, user]);
// Accept both old and new API formats for eligibility
let eligible = false;
if (userStatus) {
if (userStatus.email_verified !== undefined) {
eligible =
userStatus.email_verified === 1 &&
userStatus.documents_uploaded === 1 &&
userStatus.profile_completed === 1;
} else if (userStatus.steps) {
const steps = userStatus.steps;
const required = ["email_verified", "profile_completed", "documents_uploaded"];
eligible = required.every(
key =>
steps.find(s => s.key === key && (s.completed === true || s.completed === 1))
);
}
}
if (!eligible) {
log("[SignContractGuard] Not eligible for contract route. Rendering null.");
return null;
}
log("[SignContractGuard] Eligible for contract route. Rendering children.");
return children;
}

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,41 @@
import React, { useEffect } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { toast } from "react-hot-toast";
import useAuthStore from "../store/authStore";
import { log } from "../utils/logger";
function AdminAuthMiddleware({ children }) {
const user = useAuthStore((s) => s.user);
const isAuthReady = useAuthStore((s) => s.isAuthReady);
const navigate = useNavigate();
log("🔍 AdminAuthMiddleware: isAuthReady:", isAuthReady, "user:", user);
useEffect(() => {
if (!isAuthReady) return;
if (user && user.role !== "admin" && user.role !== "super_admin") {
log("AdminAuthMiddleware: Access denied for user role:", user.role);
toast.error("Access denied. You are not permitted to access admin areas.");
navigate("/dashboard", { replace: true });
}
}, [isAuthReady, user, navigate]);
if (!isAuthReady) {
// Wait for bootstrap refresh to complete
return null;
}
if (!user) {
log("AdminAuthMiddleware: No user, redirecting to /login");
return <Navigate to="/login" replace />;
}
if (user.role !== "admin" && user.role !== "super_admin") {
// Don't render anything while redirecting
return null;
}
return children;
}
export default AdminAuthMiddleware;

186
src/auth/AuthWrapper.jsx Normal file
View File

@ -0,0 +1,186 @@
import React, { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import useAuthStore from "../store/authStore";
import { log } from "../utils/logger";
// Helper to check if JWT is expired
function isTokenExpired(token) {
if (!token) return true;
try {
const [, payload] = token.split(".");
const { exp } = JSON.parse(atob(payload));
return exp * 1000 < Date.now();
} catch {
return true;
}
}
// Helper to decode JWT and get expiry
function getTokenExpiry(token) {
if (!token) return null;
try {
const [, payload] = token.split(".");
const { exp } = JSON.parse(atob(payload));
return exp ? new Date(exp * 1000) : null;
} catch {
return null;
}
}
function AuthWrapper({ children }) {
const accessToken = useAuthStore((s) => s.accessToken);
const user = useAuthStore((s) => s.user);
const setAccessToken = useAuthStore((s) => s.setAccessToken);
const setUser = useAuthStore((s) => s.setUser);
const clearAuth = useAuthStore((s) => s.clearAuth);
const logout = useAuthStore((s) => s.logout);
const isAuthReady = useAuthStore((s) => s.isAuthReady);
const setAuthReady = useAuthStore((s) => s.setAuthReady);
const refreshAuthToken = useAuthStore((s) => s.refreshAuthToken);
const navigate = useNavigate();
const isRefreshingRef = useRef(false);
const refreshTimeoutRef = useRef(null);
log("🛡️ AuthWrapper: Component rendered");
log("🔍 AuthWrapper: Current token:", accessToken ? `${accessToken.substring(0, 20)}...` : "None");
if (accessToken) {
const expiry = getTokenExpiry(accessToken);
log("⏳ AuthWrapper: Current token expiry:", expiry ? expiry.toLocaleString() : "Unknown");
}
// --- Initial bootstrap: attempt refresh via httpOnly cookie to obtain access token ---
useEffect(() => {
log("🔄 AuthWrapper: Bootstrapping auth via store.refreshAuthToken()");
let mounted = true;
const doBootstrapRefresh = async () => {
try {
// call centralized, deduped refresh
const ok = await refreshAuthToken();
if (!ok) {
log("❌ AuthWrapper: Bootstrap refresh returned false — performing logout+redirect");
await logout().catch((e) => log("❌ AuthWrapper: logout error during bootstrap:", e));
if (mounted) navigate("/login", { replace: true });
} else {
log("✅ AuthWrapper: Bootstrap refresh succeeded (store set token/user)");
}
} catch (error) {
log("❌ AuthWrapper: Bootstrap refresh error:", error);
await logout().catch((e) => log("❌ AuthWrapper: logout error during bootstrap:", e));
if (mounted) navigate("/login", { replace: true });
} finally {
if (mounted) {
setAuthReady(true);
log("🔔 AuthWrapper: Auth ready set to true");
}
}
};
doBootstrapRefresh();
return () => { mounted = false; };
// eslint-disable-next-line
}, []);
// --- Periodic Token Check ---
useEffect(() => {
log("🕒 AuthWrapper: Setting up periodic token expiry check (every 14 minutes)");
const interval = setInterval(async () => {
log("🕒 AuthWrapper: Periodic check running");
if (accessToken && isTokenExpired(accessToken)) {
log("⏰ AuthWrapper: Token expired (periodic check), attempting refresh");
const refreshed = await refreshAuthToken();
if (!refreshed) {
log("❌ AuthWrapper: Token refresh failed or expired (periodic check), logging out user");
clearAuth();
navigate("/login", { replace: true });
}
} else {
log("🟢 AuthWrapper: Token still valid (periodic check)");
}
}, 14 * 60 * 1000); // every 14 minutes
return () => {
log("🛑 AuthWrapper: Clearing periodic token expiry check interval");
clearInterval(interval);
};
}, [accessToken, navigate]);
// --- Automatic Token Refresh Timer ---
useEffect(() => {
// Clear any existing timer
if (refreshTimeoutRef.current) {
log("🛑 AuthWrapper: Clearing previous scheduled refresh timer");
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
// Helper to schedule refresh
function scheduleRefresh() {
if (!accessToken) {
log("⚠️ AuthWrapper: No accessToken, not scheduling refresh");
return;
}
const expiry = getTokenExpiry(accessToken);
if (!expiry) {
log("⚠️ AuthWrapper: Could not determine token expiry, not scheduling refresh");
return;
}
// Refresh 10 seconds before expiry, but never less than 1 second from now
const now = Date.now();
const msUntilExpiry = expiry.getTime() - now;
const msUntilRefresh = Math.max(msUntilExpiry - 10000, 1000);
if (msUntilRefresh > 0) {
log(
`⏰ AuthWrapper: Scheduling token refresh in ${Math.round(msUntilRefresh / 1000)} seconds (at ${new Date(now + msUntilRefresh).toLocaleTimeString()})`
);
refreshTimeoutRef.current = setTimeout(async () => {
log("🚨 AuthWrapper: Scheduled token refresh timer triggered");
await refreshAuthToken();
}, msUntilRefresh);
} else {
log(
`⚠️ AuthWrapper: Token expiry is too soon (${msUntilExpiry}ms), not scheduling refresh`
);
}
}
scheduleRefresh();
// Cleanup on unmount or accessToken change
return () => {
if (refreshTimeoutRef.current) {
log("🛑 AuthWrapper: Cleaning up scheduled refresh timer on unmount/accessToken change");
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
};
}, [accessToken]); // Reschedule when accessToken changes
// Redirect to login if not authenticated
useEffect(() => {
if (!isAuthReady) return;
// If no accessToken or user, force redirect to login
if (!accessToken || !user) {
log("🔒 AuthWrapper: No accessToken or user, redirecting to /login");
navigate("/login", { replace: true });
}
}, [isAuthReady, accessToken, user, navigate]);
if (!isAuthReady) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading authentication...</p>
</div>
</div>
);
}
return <>{children}</>;
}
export default AuthWrapper;

View File

@ -0,0 +1,43 @@
import React from "react";
import { Navigate } from "react-router-dom";
import useAuthStore from "../store/authStore";
import { log } from "../utils/logger";
function CompanyAuthMiddleware({ children }) {
const user = useAuthStore((s) => s.user);
const isAuthReady = useAuthStore((s) => s.isAuthReady);
log("🔍 CompanyAuthMiddleware: isAuthReady:", isAuthReady, "user:", user);
// Wait for auth bootstrap to finish before deciding
if (!isAuthReady) {
log("⏳ CompanyAuthMiddleware: Auth not ready yet, deferring decision");
return null;
}
if (!user) {
log("🔒 CompanyAuthMiddleware: No user found, redirecting to /login");
return <Navigate to="/login" replace />;
}
// derive userType from user object safely
const getUserType = (u) => {
if (!u) return null;
if (u.userType) return u.userType;
if (u.type) return u.type;
if (u.companyName) return "company";
return "personal";
};
const userType = getUserType(user);
log("🔍 CompanyAuthMiddleware: derived userType:", userType);
if (userType !== "company") {
log("⛔ CompanyAuthMiddleware: userType mismatch, redirecting to /dashboard");
return <Navigate to="/dashboard" replace />;
}
return children;
}
export default CompanyAuthMiddleware;

44
src/auth/OtpForm.jsx Normal file
View File

@ -0,0 +1,44 @@
import React from "react";
import { useTranslation } from "react-i18next";
function OtpForm() {
const { t } = useTranslation('auth');
return (
<div className="w-full max-w-sm sm:max-w-2xl mx-auto bg-white rounded-2xl shadow-2xl px-6 py-8 sm:px-12 sm:py-10 mt-8 mb-8">
<h2 className="text-xl sm:text-2xl font-extrabold text-blue-900 mb-2 text-center">{t('otp.heading')}</h2>
<p className="text-sm sm:text-base text-gray-500 mb-6 text-center">
{t('otp.instructions')}
</p>
<form className="space-y-6 sm:space-y-7">
<div>
<label htmlFor="otp" className="block text-sm font-medium text-blue-900 mb-2 text-center">
{t('otp.codeLabel')}
</label>
<input
id="otp"
type="text"
maxLength={6}
className="w-full px-4 py-3 rounded-lg border border-blue-200 bg-gray-50 text-lg text-blue-900 placeholder-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400 transition text-center tracking-widest"
placeholder={t('otp.codePlaceholder')}
/>
</div>
<button
type="submit"
className="w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white bg-gradient-to-r from-blue-700 via-blue-500 to-blue-400 hover:from-blue-800 hover:to-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 transition-all"
>
{t('otp.verifyAndLogin')}
</button>
<div className="flex justify-center mt-2">
<button
type="button"
className="text-blue-700 hover:text-blue-900 font-medium text-sm px-3 py-2 rounded transition focus:outline-none focus:ring-2 focus:ring-blue-300"
>
{t('otp.resendCode')}
</button>
</div>
</form>
</div>
);
}
export default OtpForm;

View File

@ -0,0 +1,43 @@
import React from "react";
import { Navigate } from "react-router-dom";
import useAuthStore from "../store/authStore";
import { log } from "../utils/logger";
function PersonalAuthMiddleware({ children }) {
const user = useAuthStore((s) => s.user);
const isAuthReady = useAuthStore((s) => s.isAuthReady);
log("🔍 PersonalAuthMiddleware: isAuthReady:", isAuthReady, "user:", user);
// Wait for auth bootstrap to finish before deciding
if (!isAuthReady) {
log("⏳ PersonalAuthMiddleware: Auth not ready yet, deferring decision");
return null;
}
if (!user) {
log("🔒 PersonalAuthMiddleware: No user found, redirecting to /login");
return <Navigate to="/login" replace />;
}
// derive userType from user object safely
const getUserType = (u) => {
if (!u) return null;
if (u.userType) return u.userType;
if (u.type) return u.type;
if (u.companyName) return "company";
return "personal";
};
const userType = getUserType(user);
log("🔍 PersonalAuthMiddleware: derived userType:", userType);
if (userType !== "personal") {
log("⛔ PersonalAuthMiddleware: userType mismatch, redirecting to /dashboard");
return <Navigate to="/dashboard" replace />;
}
return children;
}
export default PersonalAuthMiddleware;

View File

@ -0,0 +1,20 @@
import React from "react";
import { Navigate } from "react-router-dom";
import useAuthStore from "../../store/authStore";
function ProtectedRoute({ children, allowedRoles = [] }) {
const user = useAuthStore((s) => s.user);
if (!user) {
return <Navigate to="/login" replace />;
}
// Ensure allowedRoles is an array and check if the user's userType or role matches
if (!Array.isArray(allowedRoles) || (!allowedRoles.includes(user.userType) && !allowedRoles.includes(user.role))) {
return <Navigate to="/dashboard" replace />;
}
return children;
}
export default ProtectedRoute;

View File

@ -0,0 +1,109 @@
import React from "react";
// Utility to detect mobile devices
function isMobileDevice() {
if (typeof navigator === "undefined") return false;
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
}
function GlobalAnimatedBackground() {
// Always use dashboard style for a uniform look
const bgGradient = "linear-gradient(135deg, #1e293b 0%, #334155 100%)";
// Detect small screens (mobile/tablet)
const isMobile = isMobileDevice();
if (isMobile) {
// Render only the static background gradient and overlay, no animation
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 0,
width: "100vw",
height: "100vh",
background: bgGradient,
transition: "background 0.5s",
pointerEvents: "none",
}}
aria-hidden="true"
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
</div>
);
}
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 0,
width: "100vw",
height: "100vh",
background: bgGradient,
transition: "background 0.5s",
pointerEvents: "none",
}}
aria-hidden="true"
>
{/* Overlays */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
<div className="absolute top-10 left-10 w-64 h-1 bg-blue-300 opacity-50 animate-slide-loop"></div>
<div className="absolute bottom-20 right-20 w-48 h-1 bg-blue-200 opacity-40 animate-slide-loop"></div>
<div className="absolute top-1/3 left-1/4 w-72 h-1 bg-blue-400 opacity-30 animate-slide-loop"></div>
<div className="absolute top-16 left-1/3 w-32 h-32 bg-blue-500 rounded-full opacity-50 animate-float"></div>
<div className="absolute bottom-24 right-1/4 w-40 h-40 bg-blue-600 rounded-full opacity-40 animate-float"></div>
<div className="absolute top-1/2 left-1/2 w-24 h-24 bg-blue-700 rounded-full opacity-30 animate-float"></div>
<div className="absolute top-1/4 left-1/5 w-20 h-20 bg-blue-300 rounded-lg opacity-40 animate-float-slow"></div>
<div className="absolute bottom-1/3 right-1/3 w-28 h-28 bg-blue-400 rounded-lg opacity-30 animate-float-slow"></div>
<style>
{`
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
}
@keyframes float-slow {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes slide-loop {
0% {
transform: translateX(0);
opacity: 1;
}
80% {
opacity: 1;
}
100% {
transform: translateX(-100vw);
opacity: 0;
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-float-slow {
animation: float-slow 8s ease-in-out infinite;
}
.animate-slide-loop {
animation: slide-loop 12s linear infinite;
}
`}
</style>
</div>
);
}
export default GlobalAnimatedBackground;

View File

@ -0,0 +1,54 @@
import React from "react";
const GlobalMobileBackground = () => (
<div
className="fixed inset-0 w-screen h-screen z-0 pointer-events-none bg-gradient-to-br from-blue-900 via-blue-800 to-blue-600"
aria-hidden="true"
>
<svg
width="100%"
height="100%"
viewBox="0 0 430 932"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute inset-0 w-full h-full"
style={{ zIndex: 0 }}
>
{/* Thin diagonal lines */}
<line
x1="30"
y1="100"
x2="400"
y2="200"
stroke="#3b82f6"
strokeWidth="2"
opacity="0.16"
/>
<line
x1="0"
y1="400"
x2="430"
y2="600"
stroke="#2563eb"
strokeWidth="2"
opacity="0.13"
/>
<line
x1="50"
y1="800"
x2="380"
y2="900"
stroke="#1e40af"
strokeWidth="2"
opacity="0.13"
/>
{/* Subtle circles */}
<circle cx="60" cy="60" r="18" fill="#3b82f6" opacity="0.13" />
<circle cx="370" cy="120" r="12" fill="#2563eb" opacity="0.11" />
<circle cx="100" cy="870" r="24" fill="#1e40af" opacity="0.11" />
<circle cx="350" cy="850" r="16" fill="#818cf8" opacity="0.09" />
</svg>
</div>
);
export default GlobalMobileBackground;

View File

@ -0,0 +1,42 @@
import React from "react";
import Header from "./nav/Header";
import Footer from "./nav/Footer";
import GlobalAnimatedBackground from "../background/GlobalAnimatedBackground";
import GlobalMobileBackground from "../background/GlobalMobileBackground"; // import mobile background
import { log } from "../utils/logger"; // import logger
// Utility to detect mobile devices
function isMobileDevice() {
if (typeof navigator === "undefined") return false;
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
function PageLayout({ children, showHeader = true, showFooter = true }) {
const isMobile = isMobileDevice();
log("📱 PageLayout: isMobile =", isMobile);
return (
<div className="min-h-screen w-full flex flex-col relative overflow-x-hidden bg-white">
{/* Background only behind content, header, and footer */}
<div className="absolute inset-0 w-full h-full z-0 pointer-events-none">
{!isMobile && <GlobalAnimatedBackground />}
{isMobile && <GlobalMobileBackground />}
</div>
{showHeader && (
<div className="relative z-50 w-full">
<Header />
</div>
)}
{/* Main content */}
<div className="flex-1 relative z-10 w-full flex flex-col">
{children}
</div>
{showFooter && (
<div className="relative z-20 w-full">
<Footer />
</div>
)}
</div>
);
}
export default PageLayout;

View File

@ -0,0 +1,110 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import useAuthStore from "../store/authStore";
import { showToast } from "./toast/toastUtils.js";
import { log } from "../utils/logger"; // import logger
function RouteProtection({ children, requiredUserType, requiredRole }) {
const user = useAuthStore((state) => state.user);
const accessToken = useAuthStore((state) => state.accessToken);
const navigate = useNavigate();
const [isChecking, setIsChecking] = useState(true);
log("🛡️ RouteProtection: Checking access");
log("🔍 User:", user);
log("🔑 Token:", accessToken ? "Present" : "Missing");
log("📋 Required User Type:", requiredUserType);
log("🎯 Required Role:", requiredRole);
useEffect(() => {
const checkPermissions = () => {
log("🔍 RouteProtection: Starting permission check");
// If no token, redirect to login
if (!accessToken) {
log("❌ RouteProtection: No token, redirecting to login");
navigate("/login", { replace: true });
return;
}
// If no user data, wait a moment for it to load
if (!user) {
log("⏳ RouteProtection: No user data, waiting...");
const timeout = setTimeout(() => {
log("⏰ RouteProtection: Timeout waiting for user data");
setIsChecking(false);
}, 2000);
return () => clearTimeout(timeout);
}
log("👤 RouteProtection: User data available");
log("🔍 User Type:", user.userType);
log("🔍 User Role:", user.role);
// Check user type if required
if (requiredUserType && user.userType !== requiredUserType) {
log(`❌ RouteProtection: User type mismatch. Required: ${requiredUserType}, Got: ${user.userType}`);
showToast({ type: "error", message: `Access denied. This page is only for ${requiredUserType} users.` });
navigate("/dashboard", { replace: true });
return;
}
// Check role if required (admin role protection)
if (requiredRole) {
const userRole = user.role || 'user'; // Default to 'user' if role is undefined
log(`🔍 RouteProtection: Role check. Required: ${requiredRole}, Got: ${userRole}`);
if (userRole !== requiredRole) {
log(`❌ RouteProtection: Role mismatch. Required: ${requiredRole}, Got: ${userRole}`);
if (requiredRole === 'admin') {
showToast({ type: "error", message: "Access denied. You are not permitted to access admin areas." });
} else {
showToast({ type: "error", message: `Access denied. You don't have the required permissions (${requiredRole}).` });
}
navigate("/dashboard", { replace: true });
return;
}
}
log("✅ RouteProtection: Access granted");
setIsChecking(false);
};
checkPermissions();
}, [user, accessToken, requiredUserType, requiredRole, navigate]);
// Show loading while checking permissions
if (isChecking) {
log("⏳ RouteProtection: Still checking permissions...");
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Checking permissions...</p>
</div>
</div>
);
}
if (!user) {
log("❌ RouteProtection: No user data after timeout");
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-600">Authentication error. Please try logging in again.</p>
<button
onClick={() => navigate("/login")}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Go to Login
</button>
</div>
</div>
);
}
log("✅ RouteProtection: Rendering protected content");
return <>{children}</>;
}
export default RouteProtection;

View File

@ -0,0 +1,62 @@
import { authFetch } from "../../../../utils/authFetch";
import { log } from "../../../../utils/logger";
export async function fetchAdminUserStats(accessToken) {
log("fetchAdminUserStats called", { accessToken });
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/admin/user-stats`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) {
log("fetchAdminUserStats failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchAdminUserStats success, response:", json);
return json;
}
export async function fetchAdminPermissions(accessToken) {
log("fetchAdminPermissions called", { accessToken });
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/permissions`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) {
log("fetchAdminPermissions failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchAdminPermissions success, response:", json);
return json;
}
export async function fetchAdminServerStatus(accessToken) {
log("fetchAdminServerStatus called", { accessToken });
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/admin/server-status`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) {
log("fetchAdminServerStatus failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchAdminServerStatus success, response:", json);
return json;
}

View File

@ -0,0 +1,63 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { useAdminPermissions } from "../hooks/useAdminDashboard";
import { useTranslation } from "react-i18next";
function StatusPermissionManagement() {
const { permissions, loading } = useAdminPermissions();
const navigate = useNavigate();
const { t } = useTranslation("admin_dashboard");
return (
<div
className="bg-purple-50 rounded-xl shadow p-8 flex flex-col cursor-pointer hover:shadow-xl transition"
onClick={() => navigate("/admin/permission-management")}
tabIndex={0}
role="button"
aria-label="Go to Permission Management"
>
<div className="flex items-center mb-4">
<svg
className="w-8 h-8 text-purple-400 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<div>
<h3 className="text-xl font-bold text-purple-900">
{t("sections.permissionManagement")}
</h3>
<p className="text-gray-600 text-sm">
{t("descriptions.permissionManagement")}
</p>
</div>
</div>
<div className="flex flex-col gap-2 mb-6">
<div className="flex justify-between text-gray-700">
<span>{t("stats.permissions")}</span>
<span className="font-bold">
{loading ? "..." : permissions.length}
</span>
</div>
</div>
<button
className="mt-auto px-4 py-2 bg-purple-100 text-purple-700 rounded border border-purple-300 font-semibold hover:bg-purple-200 transition"
onClick={(e) => {
e.stopPropagation();
navigate("/admin/permission-management");
}}
>
{t("buttons.goPermissionManagement")}
</button>
</div>
);
}
export default StatusPermissionManagement;

View File

@ -0,0 +1,85 @@
import React from "react";
import { useAdminServerStatus } from "../hooks/useAdminDashboard";
import { useTranslation } from "react-i18next";
function formatUptime(uptime) {
if (!uptime) return "-";
if (typeof uptime === "string") return uptime;
if (typeof uptime === "object" && (uptime.days !== undefined || uptime.hours !== undefined)) {
const { days = 0, hours = 0, minutes = 0 } = uptime;
let str = "";
if (days) str += `${days} day${days !== 1 ? "s" : ""}`;
if (hours) str += (str ? ", " : "") + `${hours} hour${hours !== 1 ? "s" : ""}`;
if (minutes) str += (str ? ", " : "") + `${minutes} min${minutes !== 1 ? "s" : ""}`;
return str || "-";
}
return String(uptime);
}
function formatMemory(memory) {
if (!memory) return "-";
if (typeof memory === "object" && memory.used !== undefined && memory.total !== undefined) {
return `${memory.used} / ${memory.total}`;
}
return String(memory);
}
function formatLog(log) {
// Handles log objects like {time, level, message}
if (typeof log === "object" && log !== null) {
const time = log.time || log.timestamp || "";
const level = log.level || "";
const message = log.message || "";
return `[${time}] ${level}: ${message}`;
}
return String(log);
}
function StatusServerLogs() {
const { server, loading } = useAdminServerStatus();
const { t } = useTranslation('admin_dashboard');
return (
<section className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 mt-4">
<div className="flex items-center mb-6">
<svg className="w-8 h-8 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 17a1 1 0 001 1h14a1 1 0 001-1V7a1 1 0 00-1-1H5a1 1 0 00-1 1v10zm0 0V7m16 10V7M8 11h8M8 15h4"/>
</svg>
<h2 className="text-xl font-bold text-gray-900">{t('sections.serverStatusLogs')}</h2>
</div>
<div className="mb-4">
<p className="text-gray-600">
{t('server.status')}:{" "}
<span className={`font-semibold ${server?.status === "online" ? "text-green-600" : "text-red-600"}`}>
{loading ? "..." : (server?.status ? t(`server.${server.status}`) : t('server.offline'))}
</span>
</p>
<p className="text-gray-600">
{t('server.uptime')}: <span className="font-semibold">{loading ? "..." : formatUptime(server?.uptime)}</span>
</p>
<p className="text-gray-600">
{t('server.cpuUsage')}: <span className="font-semibold">{loading ? "..." : (server?.cpuUsagePercent !== undefined ? `${server.cpuUsagePercent}%` : "-")}</span>
</p>
<p className="text-gray-600">
{t('server.memoryUsage')}: <span className="font-semibold">{loading ? "..." : formatMemory(server?.memory)}</span>
</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">{t('server.recentErrorLogs')}</h3>
<ul className="text-sm text-red-500 space-y-1">
{loading ? (
<li>{t('server.loadingLogs')}</li>
) : server?.logs && Array.isArray(server.logs) && server.logs.length > 0 ? (
server.logs.map((log, idx) => (
<li key={idx}>{formatLog(log)}</li>
))
) : (
<li>{t('server.noLogs')}</li>
)}
</ul>
</div>
</section>
);
}
export default StatusServerLogs;

View File

@ -0,0 +1,93 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { useAdminUserStats } from "../hooks/useAdminDashboard";
import { useTranslation } from "react-i18next";
function StatusUserManagement() {
const { stats, loading } = useAdminUserStats();
const navigate = useNavigate();
const { t } = useTranslation("admin_dashboard");
return (
<div
className="bg-blue-50 rounded-xl shadow p-8 flex flex-col cursor-pointer hover:shadow-xl transition"
onClick={() => navigate("/admin/user-management")}
tabIndex={0}
role="button"
aria-label="Go to User Management"
>
<div className="flex items-center mb-4">
<svg
className="w-8 h-8 text-blue-400 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<div>
<h3 className="text-xl font-bold text-blue-900">
{t("sections.userManagement")}
</h3>
<p className="text-gray-600 text-sm">
{t("descriptions.userManagement")}
</p>
</div>
</div>
<div className="flex flex-col gap-2 mb-6">
<div className="flex justify-between text-gray-700">
<span>{t("stats.totalUsers")}</span>
<span className="font-bold">
{loading ? "..." : stats?.totalUsers ?? 0}
</span>
</div>
<div className="flex justify-between text-gray-700">
<span>{t("stats.adminUsers")}</span>
<span className="font-bold">
{loading ? "..." : stats?.adminUsers ?? 0}
</span>
</div>
<div className="flex justify-between text-gray-700">
<span>{t("stats.verificationPending")}</span>
<span className="font-bold">
{loading ? "..." : stats?.verificationPending ?? 0}
</span>
</div>
<div className="flex justify-between text-gray-700">
<span>{t("stats.activeUsers")}</span>
<span className="font-bold">
{loading ? "..." : stats?.activeUsers ?? 0}
</span>
</div>
<div className="flex justify-between text-gray-700">
<span>{t("stats.personalUsers")}</span>
<span className="font-bold">
{loading ? "..." : stats?.personalUsers ?? 0}
</span>
</div>
<div className="flex justify-between text-gray-700">
<span>{t("stats.companyUsers")}</span>
<span className="font-bold">
{loading ? "..." : stats?.companyUsers ?? 0}
</span>
</div>
</div>
<button
className="mt-auto px-4 py-2 bg-orange-100 text-orange-700 rounded border border-orange-300 font-semibold hover:bg-orange-200 transition"
onClick={(e) => {
e.stopPropagation();
navigate("/admin/verify-users");
}}
>
{t("buttons.verifyUsers")}
</button>
</div>
);
}
export default StatusUserManagement;

View File

@ -0,0 +1,101 @@
import { useState, useEffect, useCallback } from "react";
import useAuthStore from "../../../../store/authStore";
import {
fetchAdminUserStats,
fetchAdminPermissions,
fetchAdminServerStatus,
} from "../api/adminDashboardApi";
import { log } from "../../../../utils/logger";
export function useAdminUserStats() {
const accessToken = useAuthStore((s) => s.accessToken);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(() => {
setLoading(true);
setError("");
log("useAdminUserStats: loading stats");
fetchAdminUserStats(accessToken)
.then((data) => {
log("useAdminUserStats: fetched stats", data);
setStats(data.stats || data);
setError("");
})
.catch((err) => {
log("useAdminUserStats: error loading stats", err);
setError("Failed to load user stats: " + err.message);
setStats(null);
})
.finally(() => setLoading(false));
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
return { stats, loading, error, reload: load };
}
export function useAdminPermissions() {
const accessToken = useAuthStore((s) => s.accessToken);
const [permissions, setPermissions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(() => {
setLoading(true);
setError("");
log("useAdminPermissions: loading permissions");
fetchAdminPermissions(accessToken)
.then((data) => {
log("useAdminPermissions: fetched permissions", data);
setPermissions(data.permissions || []);
setError("");
})
.catch((err) => {
log("useAdminPermissions: error loading permissions", err);
setError("Failed to load permissions: " + err.message);
setPermissions([]);
})
.finally(() => setLoading(false));
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
return { permissions, loading, error, reload: load };
}
export function useAdminServerStatus() {
const accessToken = useAuthStore((s) => s.accessToken);
const [server, setServer] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(() => {
setLoading(true);
setError("");
log("useAdminServerStatus: loading server status");
fetchAdminServerStatus(accessToken)
.then((data) => {
log("useAdminServerStatus: fetched server status", data);
setServer(data);
setError("");
})
.catch((err) => {
log("useAdminServerStatus: error loading server status", err);
setError("Failed to load server status: " + err.message);
setServer(null);
})
.finally(() => setLoading(false));
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
return { server, loading, error, reload: load };
}

View File

@ -0,0 +1,75 @@
import React from "react";
import PageLayout from "../../../PageLayout";
import RouteProtection from "../../../RouteProtection";
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
import StatusUserManagement from "../components/StatusUserManagement";
import StatusPermissionManagement from "../components/StatusPermissionManagement";
import StatusServerLogs from "../components/StatusServerLogs";
import { useTranslation } from "react-i18next";
function AdminDashboard() {
const { t } = useTranslation('admin_dashboard');
return (
<RouteProtection requiredRole="admin">
<PageLayout showHeader={true} showFooter={true}>
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
<GlobalAnimatedBackground />
<div className="relative z-10 w-full flex justify-center">
<div
className="rounded-lg shadow-lg p-4 sm:p-8 bg-gray-100 w-full"
style={{
marginTop: "0.5%", // Match dashboard/referral management top margin
marginBottom: "2%",
width: "100%",
maxWidth: "1600px",
}}
>
{/* Header and subheader, consistent with Dashboard/Referral Management */}
<h2 className="text-2xl sm:text-5xl font-extrabold text-blue-900 mb-2 text-center tracking-tight">
{t('heading.main')}
</h2>
<p className="text-xs sm:text-base text-blue-900 text-center font-semibold mb-4 sm:mb-8">
{t('subtitle.main')}
</p>
<div className="bg-white bg-opacity-90 rounded-lg shadow-lg p-4 sm:p-6 mb-8">
{/* Prominent warning banner */}
<div className="w-full mb-8">
<div className="flex items-center w-full bg-red-100 border-2 border-red-500 rounded-xl shadow px-4 sm:px-8 py-4 sm:py-5">
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-red-600 mr-3 sm:mr-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z"
/>
</svg>
<span className="font-bold text-red-700 text-base sm:text-xl">
{t('warning.banner')}
</span>
</div>
</div>
<p className="text-gray-600 text-sm sm:text-base text-center">
{t('subtitle.main')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8 mb-12">
<StatusUserManagement />
<StatusPermissionManagement />
</div>
<div className="mt-10 sm:mt-16">
<StatusServerLogs />
</div>
</div>
</div>
</div>
</PageLayout>
</RouteProtection>
);
}
export default AdminDashboard;

View File

@ -0,0 +1,7 @@
// Dummy hook for future API integration
const useContractDashboardApi = () => {
// Will fetch documents from backend later
return {};
};
export default useContractDashboardApi;

View File

@ -0,0 +1,34 @@
import React, { useState } from "react";
import useContractDashboard from "../hooks/useContractDashboard";
const TemplateStateButton = ({ id, state, refreshTemplates }) => {
const { updateTemplateState, uploading } = useContractDashboard();
const [localState, setLocalState] = useState(state);
const handleToggleState = async () => {
const newState = localState === "active" ? "inactive" : "active";
await updateTemplateState(id, newState);
setLocalState(newState);
if (typeof refreshTemplates === "function") {
refreshTemplates();
}
};
return (
<button
type="button"
className={`px-2 py-1 rounded font-semibold text-xs ${
localState === "active"
? "bg-green-600 text-white"
: "bg-gray-400 text-white"
}`}
onClick={handleToggleState}
disabled={uploading}
title={localState === "active" ? "Deaktivieren" : "Aktivieren"}
>
{localState === "active" ? "Aktiv" : "Inaktiv"}
</button>
);
};
export default TemplateStateButton;

View File

@ -0,0 +1,9 @@
import React from "react";
// This is just a placeholder, main logic is in the page file
const ContractDashboardForm = () => {
// ...form logic can be moved here later...
return null;
};
export default ContractDashboardForm;

View File

@ -0,0 +1,359 @@
import React, { useEffect, useState } from "react";
import useContractDashboard from "../hooks/useContractDashboard";
const CreateContractForm = ({
templateHtml,
setTemplateHtml,
templateName,
setTemplateName,
codeView,
setCodeView,
editorRef,
execCmd,
refreshTemplates,
selectedDoc,
}) => {
// Sync contentEditable innerHTML with templateHtml when switching from code view
useEffect(() => {
if (!codeView && editorRef.current) {
// Previously we stripped <style data-editor-style> and body-only content.
// Now we keep the raw templateHtml exactly as provided.
const incoming = typeof templateHtml === "string" ? templateHtml : "";
if (editorRef.current.innerHTML !== incoming) {
editorRef.current.innerHTML = incoming;
}
}
}, [codeView, templateHtml, editorRef]);
// Remove automatic editor list style injection (it could overwrite user <style> tags)
// useEffect removed.
// Add state for type, description, lang, and upload feedback
const [templateType, setTemplateType] = useState("");
const [templateDesc, setTemplateDesc] = useState("");
const [templateLang, setTemplateLang] = useState("de");
// Add audience state
const [templateAudience, setTemplateAudience] = useState("both");
// Add edit mode state
const [editMode, setEditMode] = useState(false);
const [templateState, setTemplateState] = useState("active");
// Helper to extract the inner content from a full HTML document
function extractHtmlBodyContent(html) {
return html || "";
}
// Fetch template file content for editing
useEffect(() => {
if (editMode && selectedDoc) {
setTemplateName(selectedDoc.name || "");
setTemplateType(selectedDoc.type || "");
setTemplateDesc(selectedDoc.description || "");
setTemplateLang(selectedDoc.lang || "de");
setTemplateState(selectedDoc.state || "active");
setTemplateAudience(selectedDoc.audience || "both");
if (selectedDoc.html) {
setTemplateHtml(selectedDoc.html); // keep untouched
} else if (selectedDoc.fileUrl) {
fetch(selectedDoc.fileUrl)
.then(res => res.text())
.then(html => setTemplateHtml(html))
.catch(() => setTemplateHtml(""));
} else {
setTemplateHtml("");
}
}
}, [editMode, selectedDoc, setTemplateHtml, setTemplateName, setTemplateType, setTemplateDesc, setTemplateLang, setTemplateState, setTemplateAudience]);
// Use hook for upload logic
const { uploadTemplate, uploading, uploadMsg, updateTemplate, updateTemplateState } = useContractDashboard();
// Helper to get the correct HTML content for saving
const getHtmlToSave = () => {
// Do not strip styles or wrapsave exactly what is in code view or editor.
if (codeView) return templateHtml;
return editorRef.current?.innerHTML || "";
};
// Helper to wrap content in valid HTML document structure
function wrapHtmlDocument(content) {
// No wrapping or alteration now; user supplies final HTML.
return content;
}
// Upload handler (create)
const handleSaveTemplate = async (e) => {
e.preventDefault();
// Debug
console.debug("Create submit audience =", templateAudience);
const htmlToSave = getHtmlToSave();
const wrappedHtml = wrapHtmlDocument(htmlToSave);
await uploadTemplate({
name: templateName,
type: templateType,
description: templateDesc,
lang: templateLang,
audience: templateAudience,
html: wrappedHtml,
setTemplateName,
setTemplateType,
setTemplateDesc,
setTemplateHtml,
setTemplateAudience,
});
// After successful upload, refresh the template list
if (typeof refreshTemplates === "function") {
refreshTemplates();
}
};
// Edit handler (update)
const handleUpdateTemplate = async (e) => {
e.preventDefault();
console.debug("Update submit audience =", templateAudience);
const htmlToSave = getHtmlToSave();
const wrappedHtml = wrapHtmlDocument(htmlToSave);
await updateTemplate({
id: selectedDoc.id,
name: templateName,
type: templateType,
description: templateDesc,
lang: templateLang,
audience: templateAudience,
html: wrappedHtml,
});
setEditMode(false);
if (typeof refreshTemplates === "function") {
refreshTemplates();
}
};
// State change handler
const handleChangeState = async (newState) => {
if (!selectedDoc) return;
await updateTemplateState(selectedDoc.id, newState);
setTemplateState(newState);
if (typeof refreshTemplates === "function") {
refreshTemplates();
}
};
// Edit button as a separate component for placement
CreateContractForm.EditButton = () =>
selectedDoc && !editMode ? (
<button
type="button"
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded font-semibold text-sm"
onClick={() => setEditMode(true)}
>
Template bearbeiten
</button>
) : null;
// Code view toggle (in toolbar) - wrap editor content into a full HTML document when switching to code view
const codeViewToggleButton = (
<button
type="button"
className="p-1 border rounded bg-blue-100 text-blue-900"
title="Code View"
onClick={() => {
// No style stripping or wrapping; just toggle.
setCodeView(v => !v);
}}
>
<span className="material-icons" style={{ fontSize: 16 }}>
{codeView ? "edit" : "code"}
</span>
</button>
);
return (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-3 text-blue-700">
{editMode ? "Vertragstemplate bearbeiten" : "Neues Vertragstemplate erstellen"}
</h2>
<form
onSubmit={editMode ? handleUpdateTemplate : handleSaveTemplate}
className="flex flex-col gap-3 bg-gray-50 p-4 rounded shadow"
>
<input
type="text"
placeholder="Template Name"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
required
style={{ minHeight: "32px" }}
/>
<input
type="text"
placeholder="Template Type/Kategorie"
value={templateType}
onChange={(e) => setTemplateType(e.target.value)}
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
required
/>
<textarea
placeholder="Beschreibung (optional)"
value={templateDesc}
onChange={(e) => setTemplateDesc(e.target.value)}
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
rows={2}
/>
<select
value={templateLang}
onChange={e => setTemplateLang(e.target.value)}
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
>
<option value="de">Deutsch</option>
<option value="en">Englisch</option>
</select>
{/* Audience selector */}
<select
value={templateAudience}
onChange={e => setTemplateAudience(e.target.value)}
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
>
<option value="personal">Personal</option>
<option value="company">Unternehmen</option>
<option value="both">Beide</option>
</select>
{/* Rich Text Editor Toolbar */}
<div className="flex gap-1 mb-2 bg-white border border-gray-200 rounded px-1 py-1 flex-wrap items-center">
{/* Basic formatting icons (smaller) */}
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Bold" onClick={() => execCmd("bold")}>
<span className="material-icons" style={{fontSize:16}}>format_bold</span>
</button>
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Italic" onClick={() => execCmd("italic")}>
<span className="material-icons" style={{fontSize:16}}>format_italic</span>
</button>
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Underline" onClick={() => execCmd("underline")}>
<span className="material-icons" style={{fontSize:16}}>format_underlined</span>
</button>
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Align Left" onClick={() => execCmd("justifyLeft")}>
<span className="material-icons" style={{fontSize:16}}>format_align_left</span>
</button>
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Align Center" onClick={() => execCmd("justifyCenter")}>
<span className="material-icons" style={{fontSize:16}}>format_align_center</span>
</button>
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Align Right" onClick={() => execCmd("justifyRight")}>
<span className="material-icons" style={{fontSize:16}}>format_align_right</span>
</button>
{/* Heading dropdown */}
<select
className="p-1 border rounded bg-white text-blue-900 text-sm"
onChange={e => {
const val = e.target.value;
if (val) execCmd("formatBlock", val);
e.target.selectedIndex = 0;
}}
defaultValue=""
style={{ minWidth: 70 }}
>
<option value="" disabled>Texttyp</option>
<option value="p">Absatz</option>
<option value="h1">H1</option>
<option value="h2">H2</option>
<option value="h3">H3</option>
<option value="h4">H4</option>
<option value="h5">H5</option>
<option value="h6">H6</option>
<option value="pre">Code</option>
<option value="blockquote">Zitat</option>
</select>
{/* List dropdown */}
<select
className="p-1 border rounded bg-white text-blue-900 text-sm"
onChange={e => {
const val = e.target.value;
if (val === "ul") execCmd("insertUnorderedList");
else if (val === "ol") execCmd("insertOrderedList");
e.target.selectedIndex = 0;
}}
defaultValue=""
style={{ minWidth: 70 }}
>
<option value="" disabled>Liste</option>
<option value="ul">&#8226; Liste</option>
<option value="ol">1. Liste</option>
</select>
{/* Link dropdown */}
<select
className="p-1 border rounded bg-white text-blue-900 text-sm"
onChange={e => {
const val = e.target.value;
if (val === "link") {
const url = prompt("Link URL:");
if (url) execCmd("createLink", url);
} else if (val === "unlink") execCmd("unlink");
e.target.selectedIndex = 0;
}}
defaultValue=""
style={{ minWidth: 80 }}
>
<option value="" disabled>Link</option>
<option value="link">Link einfügen</option>
<option value="unlink">Link entfernen</option>
</select>
{/* Code view toggle */}
{codeViewToggleButton}
</div>
{/* Editor or Code View */}
{!codeView ? (
<div
ref={editorRef}
contentEditable={true}
className="contract-editor border rounded px-2 py-1 min-h-[120px] max-h-[400px] bg-white text-blue-900 text-sm overflow-auto"
style={{
outline: "none",
position: "relative",
zIndex: 1,
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
suppressContentEditableWarning
onInput={e => {
// Keep raw HTML (no filtering of <style>)
setTemplateHtml(e.currentTarget.innerHTML);
}}
/>
) : (
<textarea
className="border rounded px-2 py-1 min-h-[120px] max-h-[400px] font-mono bg-gray-100 text-blue-900 text-sm overflow-auto"
value={typeof templateHtml === "string" ? templateHtml : ""}
onChange={e => setTemplateHtml(e.target.value)}
style={{ resize: "vertical", position: "relative", zIndex: 1 }}
/>
)}
<div className="flex gap-2">
<button
type="submit"
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded font-semibold text-sm"
disabled={uploading}
>
{editMode
? uploading ? "Speichern..." : "Änderungen speichern"
: uploading ? "Hochladen..." : "Template speichern"}
</button>
{editMode && (
<button
type="button"
className="bg-gray-300 hover:bg-gray-400 text-gray-800 px-3 py-1 rounded font-semibold text-sm"
onClick={() => setEditMode(false)}
>
Abbrechen
</button>
)}
</div>
{uploadMsg && (
<div className={`mt-2 text-sm ${uploadMsg.includes("erfolgreich") ? "text-green-700" : "text-red-600"}`}>
{uploadMsg}
</div>
)}
</form>
{/* Remove edit button from bottom */}
</div>
);
};
export default CreateContractForm;

View File

@ -0,0 +1,174 @@
import { useState } from "react";
import useAuthStore from "../../../../store/authStore";
import { log } from "../../../../utils/logger";
const useContractDashboard = () => {
const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState("");
const accessToken = useAuthStore((s) => s.accessToken);
const uploadTemplate = async ({
name,
type,
description,
lang,
audience,
html,
setTemplateName,
setTemplateType,
setTemplateDesc,
setTemplateHtml,
setTemplateAudience,
}) => {
setUploading(true);
setUploadMsg("");
log("uploadTemplate called", { name, type, description, lang, audience });
try {
const htmlBlob = new Blob([html], { type: "text/html" });
const formData = new FormData();
formData.append("name", name);
formData.append("type", type);
formData.append("description", description);
formData.append("lang", lang);
formData.append("audience", audience || "both"); // frontend canonical
formData.append("user_type", audience || "both"); // backend expected
formData.append("file", htmlBlob, "template.html");
// Debug: list formData entries
for (const pair of formData.entries()) {
log("uploadTemplate formData", pair[0], pair[1]);
}
const res = await fetch(
import.meta.env.VITE_API_BASE_URL + "/api/document-templates",
{
method: "POST",
body: formData,
headers: {
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
},
}
);
log("uploadTemplate response status:", res.status);
if (res.ok) {
log("uploadTemplate success");
setUploadMsg("Template erfolgreich hochgeladen.");
setTemplateName("");
setTemplateType("");
setTemplateDesc("");
setTemplateHtml("");
if (setTemplateAudience) setTemplateAudience("both");
} else {
log("uploadTemplate failed");
setUploadMsg("Fehler beim Hochladen des Templates.");
}
} catch (err) {
log("uploadTemplate error:", err);
setUploadMsg("Fehler beim Hochladen.");
} finally {
setUploading(false);
}
};
// Edit/update template
const updateTemplate = async ({
id,
name,
type,
description,
lang,
audience,
html,
}) => {
setUploading(true);
setUploadMsg("");
log("updateTemplate called", { id, name, type, description, lang, audience });
try {
const formData = new FormData();
if (name) formData.append("name", name);
if (type) formData.append("type", type);
if (description) formData.append("description", description);
if (lang) formData.append("lang", lang);
if (audience) {
formData.append("audience", audience);
formData.append("user_type", audience); // ensure backend gets correct field
}
if (html) {
const htmlBlob = new Blob([html], { type: "text/html" });
formData.append("file", htmlBlob, "template.html");
}
// Debug
for (const pair of formData.entries()) {
log("updateTemplate formData", pair[0], pair[1]);
}
const res = await fetch(
import.meta.env.VITE_API_BASE_URL + `/api/document-templates/${id}`,
{
method: "PUT",
body: formData,
headers: {
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
},
}
);
log("updateTemplate response status:", res.status);
if (res.ok) {
log("updateTemplate success");
setUploadMsg("Template erfolgreich bearbeitet.");
} else {
log("updateTemplate failed");
setUploadMsg("Fehler beim Bearbeiten des Templates.");
}
} catch (err) {
log("updateTemplate error:", err);
setUploadMsg("Fehler beim Bearbeiten.");
} finally {
setUploading(false);
}
};
// PATCH state endpoint
const updateTemplateState = async (id, state) => {
setUploading(true);
setUploadMsg("");
log("updateTemplateState called", { id, state });
try {
const res = await fetch(
import.meta.env.VITE_API_BASE_URL + `/api/document-templates/${id}/state`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
},
body: JSON.stringify({ state }),
}
);
log("updateTemplateState response status:", res.status);
if (res.ok) {
log("updateTemplateState success");
setUploadMsg("Status erfolgreich geändert.");
} else {
log("updateTemplateState failed");
setUploadMsg("Fehler beim Ändern des Status.");
}
} catch (err) {
log("updateTemplateState error:", err);
setUploadMsg("Fehler beim Ändern des Status.");
} finally {
setUploading(false);
}
};
return {
uploadTemplate,
updateTemplate,
updateTemplateState,
uploading,
uploadMsg,
};
};
export default useContractDashboard;

View File

@ -0,0 +1,971 @@
import React, { useState, useRef, useEffect } from "react";
import PageLayout from "../../../PageLayout";
import CreateContractForm from "../components/createContractForm";
import useAuthStore from "../../../../store/authStore";
import TemplateStateButton from "../components/TemplateStateButton"; // Import the new button component
import { log } from "../../../../utils/logger"; // Add logger import
const ContractDashboard = () => {
const [documents, setDocuments] = useState([]);
const [selectedDocId, setSelectedDocId] = useState(null);
const [selectedVersions, setSelectedVersions] = useState({}); // per-document selected versions map (legacy)
const [selectedGroupVersions, setSelectedGroupVersions] = useState({}); // per-group selected version map
const [templateHtml, setTemplateHtml] = useState("");
const [templateName, setTemplateName] = useState("");
const [templateDesc, setTemplateDesc] = useState("");
const [codeView, setCodeView] = useState(false);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [templateError, setTemplateError] = useState("");
const [language, setLanguage] = useState("all"); // "de", "en", "all"
const [audienceFilter, setAudienceFilter] = useState("all"); // "personal","company","both","all"
const editorRef = useRef(null);
const accessToken = useAuthStore((s) => s.accessToken);
// --- Firmenstempel State ---
const [stampFile, setStampFile] = useState(null);
const [stampPreview, setStampPreview] = useState(""); // full data URL
const [stampBase64, setStampBase64] = useState(""); // raw base64 (no prefix)
const [stampMime, setStampMime] = useState("");
const [stampLabel, setStampLabel] = useState("");
const [stampUploading, setStampUploading] = useState(false);
const [stampError, setStampError] = useState("");
const [stampSuccess, setStampSuccess] = useState("");
const [stampActivateNow, setStampActivateNow] = useState(true);
const [stampList, setStampList] = useState([]); // all stamps
const [activeStamp, setActiveStamp] = useState(null); // currently active stamp object
const DEBUG_GROUP_LOGS = false; // toggle to see per-render grouping logs
// Single company stamp logic (company's own stamp always id = 1)
const hasCompanyStamp = stampList.some(s => s.id === 1);
const companyStamp = stampList.find(s => s.id === 1);
// Extensive logging for stamp state
useEffect(() => {
log("STAMP_DEBUG: stampList", stampList);
log("STAMP_DEBUG: activeStamp", activeStamp);
log("STAMP_DEBUG: hasCompanyStamp", stampList.some(s => s.id === 1), "companyStamp", stampList.find(s => s.id === 1));
log("STAMP_DEBUG: derived hasCompanyStamp", hasCompanyStamp, "companyStamp", companyStamp);
}, [stampList, activeStamp, hasCompanyStamp, companyStamp]);
// Fetch all templates from backend
const fetchTemplates = async () => {
setLoadingTemplates(true);
setTemplateError("");
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL;
// Use correct endpoint for admin dashboard
const res = await fetch(
baseUrl + "/api/document-templates",
{
method: "GET",
headers: {
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
},
}
);
if (!res.ok) {
throw new Error("Fehler beim Laden der Templates");
}
const data = await res.json();
// log raw API payload for debugging / normalization verification
log("fetchTemplates: raw data:", data);
// Added: summarize audiences returned
const audienceSummary = data.reduce((acc,d)=>{
const a = (d.audience || d.user_type || "MISSING/DEFAULT");
acc[a] = (acc[a]||0)+1;
return acc;
},{});
log("fetchTemplates: audience summary", audienceSummary);
// Normalize: ensure each doc has .audience populated from backend user_type
const normalized = data.map(d => ({
...d,
audience: d.audience || d.user_type || "both",
}));
setDocuments(normalized);
// initialize per-group selected versions map
const initialGroupSelected = {};
const groupsTemp = {};
normalized.forEach(doc => {
const groupKey = `${doc.name || ""}::${doc.lang || ""}::${doc.type || ""}::${(doc.audience || "both")}`;
if (!groupsTemp[groupKey]) groupsTemp[groupKey] = [];
groupsTemp[groupKey].push(doc);
});
Object.keys(groupsTemp).forEach(key => {
const docs = groupsTemp[key];
// prefer highest version or doc.version
const sorted = docs.slice().sort((a,b) => (b.version || 0) - (a.version || 0));
initialGroupSelected[key] = sorted[0]?.version || sorted[0]?.version || 1;
});
log("fetchTemplates: initialGroupSelected:", initialGroupSelected);
setSelectedGroupVersions(initialGroupSelected);
// initialize per-document selected versions map
const initialSelected = {};
normalized.forEach(doc => {
// normalize versions into a flat array (support both shapes)
const allVersions = Array.isArray(doc.versions)
? doc.versions.slice()
: (doc.versions ? Object.values(doc.versions).flat() : []);
// prefer a version matching the doc.lang if present
const preferred = allVersions.find(v => !v.lang || v.lang === doc.lang);
initialSelected[doc.id] = preferred?.version || allVersions[0]?.version || doc.version || 1;
});
// log normalized initial selection map
log("fetchTemplates: initialSelected (normalized):", initialSelected);
setSelectedVersions(initialSelected);
// Select first doc if available
if (normalized.length > 0) setSelectedDocId(normalized[0].id);
} catch (err) {
setTemplateError("Fehler beim Laden der Templates.");
console.error("fetchTemplates: error:", err);
} finally {
setLoadingTemplates(false);
}
};
// Firmenstempel: reset selection states
const resetStampSelection = () => {
setStampFile(null);
setStampPreview("");
setStampBase64("");
setStampMime("");
setStampLabel("");
setStampActivateNow(true);
};
// Firmenstempel: Validate + read file -> base64
const validateAndReadStampFile = (file) => {
if (!file) return;
setStampError("");
setStampSuccess("");
const allowed = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
if (!allowed.includes(file.type)) {
setStampError("Ungültiger Dateityp. Erlaubt: PNG, JPG, WEBP.");
return;
}
const maxBytes = 500 * 1024; // 500 KB
if (file.size > maxBytes) {
setStampError("Datei zu groß (max. 500 KB).");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target.result;
if (typeof dataUrl !== "string") {
setStampError("Fehler beim Lesen der Datei.");
return;
}
const commaIdx = dataUrl.indexOf(",");
const raw = commaIdx > -1 ? dataUrl.slice(commaIdx + 1) : dataUrl;
setStampPreview(dataUrl);
setStampBase64(raw);
setStampMime(file.type);
setStampFile(file);
log("stamp file loaded", { size: file.size, type: file.type });
};
reader.onerror = () => setStampError("Fehler beim Lesen der Datei.");
reader.readAsDataURL(file);
};
const authHeader = () =>
accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
// Firmenstempel: Fetch stamps (list + active)
const fetchStamps = async () => {
log("STAMP_DEBUG: fetchStamps called");
try {
const base = import.meta.env.VITE_API_BASE_URL;
log("STAMP_DEBUG: fetchStamps base", base);
const resList = await fetch(base + "/api/company-stamps/mine", {
headers: { ...authHeader() },
});
log("STAMP_DEBUG: resList status", resList.status);
let listData = [];
if (resList.ok) {
listData = await resList.json();
log("STAMP_DEBUG: raw listData", listData);
// FIX: Accept single object or array
if (Array.isArray(listData)) {
// ok
} else if (listData && typeof listData === "object") {
listData = [listData];
} else {
listData = [];
}
// normalize: add previewDataUri (backend may already supply it)
const normalized = listData.map(s => {
log("STAMP_DEBUG: normalizing stamp", s);
if (s.previewDataUri) return s;
const dataUri = s.image_base64 && s.mime_type
? `data:${s.mime_type};base64,${s.image_base64}`
: "";
return { ...s, previewDataUri: dataUri };
});
log("STAMP_DEBUG: normalized stampList", normalized);
setStampList(normalized);
} else {
log("STAMP_DEBUG: fetchStamps list failed status", resList.status);
}
const resActive = await fetch(base + "/api/company-stamps/mine/active", {
headers: { ...authHeader() },
});
log("STAMP_DEBUG: resActive status", resActive.status);
if (resActive.ok) {
const activeData = await resActive.json();
log("STAMP_DEBUG: raw activeData", activeData);
setActiveStamp(activeData || null);
} else {
log("STAMP_DEBUG: fetchStamps active failed status", resActive.status);
setActiveStamp(null);
}
} catch (e) {
log("STAMP_DEBUG: fetchStamps error", e);
}
};
// Firmenstempel: Upload stamp
const uploadStamp = async () => {
log("STAMP_DEBUG: uploadStamp called", {
hasCompanyStamp,
companyStamp,
stampBase64Len: stampBase64.length,
stampMime,
stampLabel,
});
if (hasCompanyStamp) {
log("STAMP_DEBUG: blocked upload - company stamp exists", companyStamp);
setStampError("Es existiert bereits ein Firmenstempel (ID 1). Lösche ihn zuerst um einen neuen hochzuladen.");
return;
}
if (!stampBase64 || !stampMime) {
log("STAMP_DEBUG: blocked upload - missing base64 or mime", { stampBase64, stampMime });
setStampError("Kein Stempel ausgewählt.");
return;
}
setStampUploading(true);
setStampError("");
setStampSuccess("");
try {
const body = {
base64: stampBase64,
mimeType: stampMime,
label: stampLabel || undefined,
activate: !!stampActivateNow,
};
log("STAMP_DEBUG: uploadStamp payload", body);
const res = await fetch(
import.meta.env.VITE_API_BASE_URL + "/api/company-stamps",
{
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeader(),
},
body: JSON.stringify(body),
}
);
log("STAMP_DEBUG: uploadStamp response status", res.status);
if (res.status === 201) {
const created = await res.json();
log("STAMP_DEBUG: uploadStamp created", created);
const withPreview = created.previewDataUri
? created
: {
...created,
previewDataUri: created.image_base64 && created.mime_type
? `data:${created.mime_type};base64,${created.image_base64}`
: "",
};
setStampList([withPreview]);
setStampSuccess("Stempel erfolgreich hochgeladen.");
resetStampSelection();
await fetchStamps();
} else if (res.status === 409) {
let payload = {};
try { payload = await res.json(); } catch {}
log("STAMP_DEBUG: uploadStamp 409 payload", payload);
const existing = payload.existing || {};
const normalizedExisting = existing.previewDataUri
? existing
: {
...existing,
previewDataUri: existing.image_base64 && existing.mime_type
? `data:${existing.mime_type};base64,${existing.image_base64}`
: "",
};
setStampList([normalizedExisting]);
setStampError(""); // treat as info
setStampSuccess("Vorhandener Firmenstempel geladen (Upload nicht erlaubt).");
resetStampSelection();
} else {
const ct = res.headers.get("content-type") || "";
let parsedMsg = "";
try {
if (ct.includes("application/json")) {
const j = await res.json();
parsedMsg = j.message || j.error || (typeof j === "string" ? j : JSON.stringify(j));
} else {
parsedMsg = (await res.text()) || "";
}
} catch {
parsedMsg = "Unbekannte Fehlermeldung.";
}
log("STAMP_DEBUG: uploadStamp server error", { status: res.status, parsedMsg });
setStampError(`Upload fehlgeschlagen (HTTP ${res.status}). ${parsedMsg}`);
}
} catch (e) {
log("STAMP_DEBUG: uploadStamp error", e);
setStampError("Netzwerkfehler beim Upload.");
} finally {
setStampUploading(false);
}
};
// Firmenstempel: Activate stamp
const activateStamp = async (id) => {
log("STAMP_DEBUG: activateStamp called", { id });
if (!id) return;
setStampUploading(true);
setStampError("");
setStampSuccess("");
try {
const res = await fetch(
import.meta.env.VITE_API_BASE_URL + `/api/company-stamps/${id}/activate`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
...authHeader(),
},
}
);
log("STAMP_DEBUG: activateStamp response status", res.status);
if (res.ok) {
setStampSuccess("Stempel aktiviert.");
await fetchStamps();
} else {
setStampError("Aktivierung fehlgeschlagen.");
}
} catch (e) {
log("STAMP_DEBUG: activateStamp error", e);
setStampError("Netzwerkfehler bei Aktivierung.");
} finally {
setStampUploading(false);
}
};
// Firmenstempel: Delete stamp
const deleteStamp = async (id) => {
log("STAMP_DEBUG: deleteStamp called", { id });
if (!id) return;
if (!confirm("Diesen Stempel wirklich löschen?")) return;
setStampUploading(true);
setStampError("");
setStampSuccess("");
try {
const res = await fetch(
import.meta.env.VITE_API_BASE_URL + `/api/company-stamps/${id}`,
{
method: "DELETE",
headers: { ...authHeader() },
}
);
log("STAMP_DEBUG: deleteStamp response status", res.status);
if (res.ok || res.status === 204) {
setStampSuccess("Stempel gelöscht.");
await fetchStamps();
} else {
setStampError("Löschen fehlgeschlagen.");
}
} catch (e) {
log("STAMP_DEBUG: deleteStamp error", e);
setStampError("Netzwerkfehler beim Löschen.");
} finally {
setStampUploading(false);
}
};
// Fetch on mount (templates + stamps)
useEffect(() => {
fetchTemplates();
fetchStamps();
// eslint-disable-next-line
}, []);
// Filter documents by language
const filteredDocuments = documents.filter(doc => {
const langOk = language === "all" || doc.lang === language;
const aud = (doc.audience || doc.user_type || "both");
const audienceOk = audienceFilter === "all" || aud === audienceFilter;
return langOk && audienceOk;
});
// build grouped documents (one list element per logical template)
const groupedDocuments = (() => {
const map = {};
filteredDocuments.forEach(doc => {
const key = `${doc.name || ""}::${doc.lang || ""}::${doc.type || ""}::${(doc.audience || "both")}`;
if (!map[key]) map[key] = { key, docs: [] };
map[key].docs.push(doc);
});
// sort group's docs by version desc
return Object.values(map).map(g => ({ ...g, docs: g.docs.sort((a,b) => (b.version||0) - (a.version||0)) }));
})();
// Find selected document from filtered list
const selectedDoc = filteredDocuments.find((doc) => doc.id === selectedDocId);
// Ensure selected version exists for selectedDoc when doc or language changes
useEffect(() => {
if (!selectedDoc) return;
const langKey = selectedDoc.lang || (language === "all" ? "de" : language);
// normalize to flat array and then filter to the current language (used only for initial selection)
const allVersionsForDoc = Array.isArray(selectedDoc.versions)
? selectedDoc.versions.slice()
: (selectedDoc.versions ? Object.values(selectedDoc.versions).flat() : []);
const vList = allVersionsForDoc.filter(v => !v.lang || v.lang === langKey);
setSelectedVersions(prev => {
const existing = prev[selectedDoc.id];
if (existing) return prev;
return { ...prev, [selectedDoc.id]: vList[0]?.version || selectedDoc.version || 1 };
});
}, [selectedDoc, language]);
// Get versions for selected language for the selectedDoc
const langKeyForSelected = selectedDoc
? (selectedDoc.lang || (language === "all" ? "de" : language))
: (language === "all" ? "de" : language);
// normalize and filter depending on top-level language filter
const versions = selectedDoc ? (() => {
const all = Array.isArray(selectedDoc.versions)
? selectedDoc.versions.slice()
: (selectedDoc.versions ? Object.values(selectedDoc.versions).flat() : []);
const filtered = language === "all"
? all
: all.filter(v => !v.lang || v.lang === langKeyForSelected);
// dedupe by version number and sort descending
const seen = new Set();
const unique = [];
for (const v of filtered) {
if (!v) continue;
const ver = v.version;
if (!seen.has(ver)) {
seen.add(ver);
unique.push(v);
}
}
unique.sort((a, b) => (b.version || 0) - (a.version || 0));
return unique;
})() : [];
// Derive current selected version for the selected document from the map
const currentSelectedVersion = selectedDoc ? (selectedVersions[selectedDoc.id] || versions[0]?.version || 1) : null;
// Find selected version object
const versionObj = versions.find((v) => v.version === currentSelectedVersion);
// Overview counts (adapt for new API shape)
const overview = {
de: documents.filter(doc => doc.lang === "de").length,
en: documents.filter(doc => doc.lang === "en").length,
total: documents.length,
};
// Audience label helper (missing before)
const audienceLabel = (a) =>
a === "personal" ? "Personal" : a === "company" ? "Unternehmen" : "Beide";
// Rich text editor toolbar actions
const execCmd = (cmd, value = null) => {
document.execCommand(cmd, false, value);
};
// Save template handler (dummy, just resets fields)
const handleSaveTemplate = (e) => {
e.preventDefault();
// Here you would send templateName, templateDesc, templateHtml to backend
setTemplateName("");
setTemplateDesc("");
setTemplateHtml("");
setCodeView(false);
};
// Pass fetchTemplates to CreateContractForm so it can refresh after upload
return (
<PageLayout>
<div className="flex flex-col items-center justify-center min-h-screen py-12 px-2">
<div className="w-full max-w-[80vw] bg-white rounded-2xl shadow-xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-8 text-blue-900">Contract Dashboard</h1>
{/* Firmenstempel Verwaltung */}
<div className="mb-10 border border-blue-100 rounded-xl p-5 bg-blue-50/40">
<h2 className="text-xl font-semibold mb-4 text-blue-800">Firmenstempel</h2>
{hasCompanyStamp && companyStamp && (
<div className="bg-white p-4 rounded border flex items-start gap-4 mb-6">
<img
src={companyStamp.previewDataUri || `data:${companyStamp.mime_type};base64,${companyStamp.image_base64}`}
alt="Firmenstempel"
className="h-32 object-contain border rounded bg-gray-50 p-2"
/>
<div className="text-sm">
<div className="font-semibold text-blue-900 mb-1">Firmenstempel vorhanden</div>
<div className="text-gray-600">Ein weiterer Upload ist nicht erlaubt.</div>
<div className="mt-2 text-xs text-gray-500">
ID: {companyStamp.id}{companyStamp.label ? ` | Label: ${companyStamp.label}` : ""}
</div>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => deleteStamp(companyStamp.id)}
disabled={stampUploading}
className="text-xs px-2 py-1 rounded bg-red-500 text-white hover:bg-red-600 disabled:opacity-60"
>
Löschen
</button>
{activeStamp && activeStamp.id !== companyStamp.id && (
<button
type="button"
onClick={() => activateStamp(companyStamp.id)}
disabled={stampUploading}
className="text-xs px-2 py-1 rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-60"
>
Aktivieren
</button>
)}
</div>
{stampError && <div className="text-xs text-red-600 mt-2">{stampError}</div>}
{stampSuccess && <div className="text-xs text-green-600 mt-2">{stampSuccess}</div>}
</div>
</div>
)}
{!hasCompanyStamp && (
<>
{activeStamp && (
<div className="mb-4">
<div className="text-sm font-medium text-green-700">Aktiver Stempel:</div>
<img
src={`data:${activeStamp.mime_type};base64,${activeStamp.image_base64}`}
alt="Aktiver Firmenstempel"
className="h-20 mt-2 object-contain border rounded bg-white p-2"
/>
{activeStamp.label && (
<div className="text-xs text-gray-600 mt-1">Label: {activeStamp.label}</div>
)}
</div>
)}
<div className="flex flex-col gap-3 bg-white p-4 rounded border">
<label className="text-sm font-medium text-blue-900">
Stempel-Datei (PNG / JPG / WEBP, max 500 KB)
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
className="mt-1 block text-sm"
onChange={(e) => validateAndReadStampFile(e.target.files?.[0])}
disabled={stampUploading || hasCompanyStamp}
/>
</label>
<input
type="text"
placeholder="Label (optional)"
value={stampLabel}
onChange={(e) => setStampLabel(e.target.value)}
className="border rounded px-2 py-1 text-sm"
disabled={stampUploading || hasCompanyStamp}
/>
<label className="flex items-center gap-2 text-sm text-blue-700 font-medium">
<input
type="checkbox"
checked={stampActivateNow}
onChange={(e) => setStampActivateNow(e.target.checked)}
disabled={stampUploading || hasCompanyStamp}
className="accent-blue-600"
/>
<span>Direkt aktivieren</span>
</label>
{stampPreview && (
<div className="flex items-start gap-4">
<div>
<div className="text-xs text-gray-600 mb-1">Vorschau:</div>
<img
src={stampPreview}
alt="Firmenstempel Vorschau"
className="h-24 object-contain border rounded bg-gray-50 p-2"
/>
{stampFile && (
<div className="text-[11px] text-gray-500 mt-1">
{stampFile.name} ({(stampFile.size / 1024).toFixed(1)} KB)
</div>
)}
</div>
<div className="flex flex-col gap-2">
<button
type="button"
className="text-xs px-2 py-1 rounded bg-gray-200 hover:bg-gray-300"
onClick={() => window.open(stampPreview, "_blank")}
>
Groß öffnen
</button>
<button
type="button"
className="text-xs px-2 py-1 rounded bg-gray-200 hover:bg-gray-300"
onClick={resetStampSelection}
disabled={stampUploading}
>
Entfernen
</button>
</div>
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={uploadStamp}
disabled={!stampBase64 || stampUploading || hasCompanyStamp}
className={`px-3 py-1 rounded text-sm font-semibold text-white ${
stampUploading || !stampBase64 || hasCompanyStamp
? "bg-gray-400"
: "bg-blue-600 hover:bg-blue-700"
}`}
>
{stampUploading ? "Lädt..." : "Stempel hochladen"}
</button>
</div>
{stampError && <div className="text-sm text-red-600">{stampError}</div>}
{stampSuccess && <div className="text-sm text-green-600">{stampSuccess}</div>}
</div>
</>
)}
{/* Liste aller Stempel (nur falls kein eigener existiert oder zur Übersicht) */}
{stampList.length > 0 && !hasCompanyStamp && (
<div className="mt-6">
<h3 className="text-sm font-semibold text-blue-900 mb-2">Gespeicherte Stempel</h3>
<ul className="flex flex-wrap gap-4">
{stampList.map((s) => {
const dataUrl = `data:${s.mime_type};base64,${s.image_base64}`;
const isActive = activeStamp && activeStamp.id === s.id;
return (
<li
key={s.id}
className={`border rounded p-3 bg-white w-44 flex flex-col items-center gap-2 ${
isActive ? "ring-2 ring-green-400" : ""
}`}
>
<img
src={dataUrl}
alt={s.label || "Firmenstempel"}
className="h-16 object-contain"
/>
{s.label && (
<div className="text-[11px] text-gray-600 truncate w-full text-center">
{s.label}
</div>
)}
<div className="flex flex-col w-full gap-1">
{!isActive && (
<button
type="button"
onClick={() => activateStamp(s.id)}
disabled={stampUploading}
className="text-xs px-2 py-1 rounded bg-green-600 text-white hover:bg-green-700"
>
Aktivieren
</button>
)}
<button
type="button"
onClick={() => deleteStamp(s.id)}
disabled={stampUploading}
className="text-xs px-2 py-1 rounded bg-red-500 text-white hover:bg-red-600"
>
Löschen
</button>
</div>
</li>
);
})}
</ul>
</div>
)}
</div>
{/* Overview Section */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-3 text-blue-700">Dokumenten-Übersicht</h2>
{/* Language / Audience filters (restyled & stacked) */}
<div className="flex flex-col gap-5 mb-6">
{/* Language Filter */}
<div>
<h3 className="text-sm font-semibold tracking-wide uppercase text-gray-600 mb-2">
Language Filter
</h3>
<div className="flex flex-wrap gap-2">
<button
type="button"
aria-pressed={language === "all"}
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
focus:outline-none focus:ring-2 focus:ring-offset-1
${language === "all"
? "bg-blue-600 text-white border-blue-600 focus:ring-blue-500"
: "bg-white text-blue-700 border-blue-200 hover:bg-blue-50 focus:ring-blue-400"}`}
onClick={() => setLanguage("all")}
>
Alle
</button>
<button
type="button"
aria-pressed={language === "de"}
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
focus:outline-none focus:ring-2 focus:ring-offset-1
${language === "de"
? "bg-blue-600 text-white border-blue-600 focus:ring-blue-500"
: "bg-white text-blue-700 border-blue-200 hover:bg-blue-50 focus:ring-blue-400"}`}
onClick={() => setLanguage("de")}
>
Deutsch
</button>
<button
type="button"
aria-pressed={language === "en"}
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
focus:outline-none focus:ring-2 focus:ring-offset-1
${language === "en"
? "bg-blue-600 text-white border-blue-600 focus:ring-blue-500"
: "bg-white text-blue-700 border-blue-200 hover:bg-blue-50 focus:ring-blue-400"}`}
onClick={() => setLanguage("en")}
>
Englisch
</button>
</div>
</div>
{/* User Type Filter */}
<div>
<h3 className="text-sm font-semibold tracking-wide uppercase text-gray-600 mb-2">
User Type Filter
</h3>
<div className="flex flex-wrap gap-2">
<button
type="button"
aria-pressed={audienceFilter === "all"}
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
focus:outline-none focus:ring-2 focus:ring-offset-1
${audienceFilter === "all"
? "bg-purple-600 text-white border-purple-600 focus:ring-purple-500"
: "bg-white text-purple-700 border-purple-200 hover:bg-purple-50 focus:ring-purple-400"}`}
onClick={() => setAudienceFilter("all")}
>
Alle
</button>
<button
type="button"
aria-pressed={audienceFilter === "personal"}
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
focus:outline-none focus:ring-2 focus:ring-offset-1
${audienceFilter === "personal"
? "bg-purple-600 text-white border-purple-600 focus:ring-purple-500"
: "bg-white text-purple-700 border-purple-200 hover:bg-purple-50 focus:ring-purple-400"}`}
onClick={() => setAudienceFilter("personal")}
>
Personal
</button>
<button
type="button"
aria-pressed={audienceFilter === "company"}
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
focus:outline-none focus:ring-2 focus:ring-offset-1
${audienceFilter === "company"
? "bg-purple-600 text-white border-purple-600 focus:ring-purple-500"
: "bg-white text-purple-700 border-purple-200 hover:bg-purple-50 focus:ring-purple-400"}`}
onClick={() => setAudienceFilter("company")}
>
Unternehmen
</button>
<button
type="button"
aria-pressed={audienceFilter === "both"}
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
focus:outline-none focus:ring-2 focus:ring-offset-1
${audienceFilter === "both"
? "bg-purple-600 text-white border-purple-600 focus:ring-purple-500"
: "bg-white text-purple-700 border-purple-200 hover:bg-purple-50 focus:ring-purple-400"}`}
onClick={() => setAudienceFilter("both")}
>
Beide
</button>
</div>
</div>
</div>
{loadingTemplates && (
<div className="flex items-center gap-2 text-blue-600 mb-4">
<span className="animate-spin h-5 w-5 border-4 border-blue-300 border-t-transparent rounded-full inline-block"></span>
<span>Lade Templates...</span>
</div>
)}
{templateError && (
<div className="text-red-600 mb-4">{templateError}</div>
)}
<ul className="bg-white rounded shadow p-4">
{groupedDocuments.map((group) => {
const representative = group.docs[0];
// build options from group.docs (each doc is a version)
const options = group.docs.map(d => ({ version: d.version || 1, id: d.id, note: d.note || undefined }))
// dedupe by version, keep highest-first sort from group
.filter((v, idx, arr) => arr.findIndex(x => x.version === v.version) === idx);
// selected version for this group (fallback to first option)
const groupKey = group.key;
const selectedVer = selectedGroupVersions[groupKey] || options[0]?.version || representative.version || 1;
// find the doc id for this selected version
const selectedDocForGroup = group.docs.find(d => (d.version || 1) === selectedVer) || group.docs[0];
// debug grouping for devtools
if (DEBUG_GROUP_LOGS) {
log(`group ${group.key}: options`, options, "selectedVer", selectedVer, "selectedDocId", selectedDocForGroup?.id);
}
return (
<li
key={group.key}
className={`mb-4 border-b pb-2 last:border-b-0 last:pb-0 cursor-pointer ${selectedDocId === selectedDocForGroup?.id ? "bg-blue-50" : ""}`}
onClick={() => {
// select the doc corresponding to the currently chosen version in the group
setSelectedDocId(selectedDocForGroup?.id);
}}
>
<div className="flex items-start justify-between">
<div>
<div className="font-medium text-blue-900">{representative.name}</div>
<div className="text-gray-600 text-sm">{representative.description}</div>
<div className="text-xs text-gray-500 mt-1">
Sprache: {representative.lang || "?"} | Typ: {representative.type || "-"} | Zielgruppe: {audienceLabel(representative.audience || representative.user_type || "both")}
</div>
<div className="text-xs text-gray-400 mt-1">
{group.docs.length > 1 ? `Versions: ${group.docs.map(d => d.version).join(", ")}` : `Version: ${representative.version || "-"}`}
</div>
</div>
{/* group version selector */}
<div className="ml-4">
<select
aria-label={`Version wählen für ${representative.name}`}
className="rounded px-2 py-1 text-sm font-medium bg-gray-100 text-gray-800 border border-gray-300 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-100 shadow-sm"
style={{ minWidth: 110, zIndex: 10 }}
value={selectedVer}
onChange={(e) => {
e.stopPropagation();
const ver = Number(e.target.value);
setSelectedGroupVersions(prev => ({ ...prev, [groupKey]: ver }));
// set selectedDocId to the doc in this group with the chosen version
const target = group.docs.find(d => (d.version || 1) === ver) || group.docs[0];
if (target) setSelectedDocId(target.id);
}}
onClick={(e) => e.stopPropagation()}
>
{options.map(v => (
<option key={v.version} value={v.version}>
v{v.version} {v.note ? `- ${v.note}` : ""}
</option>
))}
</select>
</div>
</div>
{/* State button */}
<div className="mt-2">
{/* Force remount on id/state change so the button reflects the currently selected version */}
<TemplateStateButton
key={`tsb-${selectedDocForGroup?.id}-${selectedDocForGroup?.state}`}
id={selectedDocForGroup?.id}
state={selectedDocForGroup?.state}
refreshTemplates={fetchTemplates}
/>
</div>
</li>
);
})}
{filteredDocuments.length === 0 && (
<li className="text-gray-500 text-center py-4">Keine Templates gefunden.</li>
)}
</ul>
</div>
{/* Document Details Section */}
<h2 className="text-xl font-semibold mb-3 text-blue-700">Ausgewähltes Dokument</h2>
{selectedDoc && (
<div className="mb-8">
<div className="bg-gray-50 rounded shadow p-4 min-h-[80px]">
<div className="font-semibold mb-2 text-blue-900 bg-white px-2 py-1 rounded inline-block">
{selectedDoc.name} ({selectedDoc.lang || "?"})
</div>
{/* Additional info */}
<div className="text-sm text-gray-700 mt-2">
<div><b>Typ:</b> {selectedDoc.type || "-"}</div>
<div><b>Beschreibung:</b> {selectedDoc.description || "-"}</div>
<div><b>Version:</b> {currentSelectedVersion || "-"}</div>
<div><b>Erstellt am:</b> {selectedDoc.createdAt ? new Date(selectedDoc.createdAt).toLocaleString() : "-"}</div>
<div><b>Letzte Änderung:</b> {selectedDoc.updatedAt ? new Date(selectedDoc.updatedAt).toLocaleString() : "-"}</div>
<div><b>ID:</b> {selectedDoc.id}</div>
<div><b>Zielgruppe:</b> {audienceLabel(selectedDoc.audience || selectedDoc.user_type || "both")}</div>
</div>
{/* Contract Preview Dropdown */}
<div className="mt-4">
<details>
<summary className="cursor-pointer font-semibold text-blue-700 hover:underline">
Vorschau anzeigen
</summary>
<div className="mt-3">
{selectedDoc.previewUrl ? (
<iframe
src={selectedDoc.previewUrl}
title="Contract Preview"
className="w-full h-96 border rounded"
/>
) : selectedDoc.html ? (
<div className="border rounded bg-white p-4 mt-2 overflow-auto max-h-[400px]" dangerouslySetInnerHTML={{ __html: selectedDoc.html }} />
) : (
<div className="text-gray-500 text-sm">Keine Vorschau verfügbar.</div>
)}
</div>
</details>
</div>
{/* Move edit button here */}
<div className="mt-4">
<CreateContractForm.EditButton />
</div>
</div>
</div>
)}
{/* Template creation form */}
<CreateContractForm
templateHtml={templateHtml}
setTemplateHtml={setTemplateHtml}
templateName={templateName}
setTemplateName={setTemplateName}
codeView={codeView}
setCodeView={setCodeView}
editorRef={editorRef}
handleSaveTemplate={handleSaveTemplate}
execCmd={execCmd}
refreshTemplates={fetchTemplates}
selectedDoc={selectedDoc}
/>
</div>
</div>
</PageLayout>
);
};
export default ContractDashboard;

View File

@ -0,0 +1,57 @@
import { authFetch } from "../../../../utils/authFetch";
import { log } from "../../../../utils/logger";
// Use VITE_API_BASE_URL directly
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
export async function fetchPermissions(accessToken) {
log("fetchPermissions called", { accessToken });
const res = await authFetch(
`${BASE_URL}/api/permissions`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
credentials: "include",
}
);
if (!res.ok) {
log("fetchPermissions failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchPermissions success, response:", json);
return json;
}
export async function createPermission({ name, description, is_active, created_by }, accessToken) {
log("createPermission called", { name, description, is_active, created_by, accessToken });
const res = await authFetch(
`${BASE_URL}/api/permissions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
credentials: "include",
body: JSON.stringify({ name, description, is_active, created_by }),
}
);
if (!res.ok) {
let errorMsg = "Failed to create permission";
try {
const data = await res.json();
errorMsg = data.message || errorMsg;
log("createPermission error response:", data);
} catch (e) {
log("createPermission error parsing response:", e);
}
log("createPermission failed:", errorMsg);
throw new Error(errorMsg);
}
const json = await res.json();
log("createPermission success, response:", json);
return json;
}

View File

@ -0,0 +1,187 @@
import React, { useState } from "react";
import { showToast } from "../../../toast/toastUtils";
import useAuthStore from "../../../../store/authStore";
import { useCreatePermission } from "../hooks/usePermissionManagement";
import { useTranslation } from "react-i18next";
function AddPermission({ onPermissionAdded }) {
const [formData, setFormData] = useState({
name: "",
description: "",
isActive: true
});
const user = useAuthStore((s) => s.user);
const { create, loading: isSubmitting, error, success } = useCreatePermission();
const { t } = useTranslation('permission_management');
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name.trim()) {
showToast({ type: "error", message: t('messages.nameRequired') });
return;
}
showToast({ type: "loading", message: t('messages.creating') });
await create({
name: formData.name.trim(),
description: formData.description.trim(),
is_active: formData.isActive,
created_by: user?.id
});
if (!error && success) {
showToast({ type: "success", message: t('messages.createSuccess') });
setFormData({
name: "",
description: "",
isActive: true
});
if (onPermissionAdded) {
onPermissionAdded();
}
} else if (error) {
showToast({ type: "error", message: error });
}
};
const handleReset = () => {
setFormData({
name: "",
description: "",
isActive: true
});
};
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center mb-6">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 mr-4">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900">{t('headings.addPermission')}</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.nameLabel')}
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t('form.namePlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black bg-white"
required
disabled={isSubmitting}
/>
<p className="mt-1 text-xs text-gray-500">
{t('form.nameHelp')}
</p>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.descriptionLabel')}
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder={t('form.descriptionPlaceholder')}
rows="3"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black bg-white"
disabled={isSubmitting}
/>
<p className="mt-1 text-xs text-gray-500">
{t('form.descriptionHelp')}
</p>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isActive"
name="isActive"
checked={formData.isActive}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
disabled={isSubmitting}
/>
<label htmlFor="isActive" className="ml-2 block text-sm text-gray-700">
{t('form.activeLabel')}
</label>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
{t('headings.namingGuidelines')}
</h3>
<div className="mt-2 text-sm text-blue-700">
<ul className="list-disc pl-5 space-y-1">
<li>{t('guidelines.b1')}</li>
<li>{t('guidelines.b2')}</li>
<li>{t('guidelines.b3')}</li>
<li>{t('guidelines.b4')}</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handleReset}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled={isSubmitting}
>
{t('form.reset')}
</button>
<button
type="submit"
disabled={isSubmitting || !formData.name.trim()}
className={`px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
isSubmitting || !formData.name.trim()
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isSubmitting ? (
<div className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('form.creating')}
</div>
) : (
t('form.submit')
)}
</button>
</div>
</form>
</div>
);
}
export default AddPermission;

View File

@ -0,0 +1,88 @@
import React from "react";
import { showToast } from "../../../toast/toastUtils";
import { usePermissions } from "../hooks/usePermissionManagement";
import { useTranslation } from "react-i18next";
function PermissionOverview({ refreshTrigger, onRefresh }) {
const { permissions, loading, error, reload } = usePermissions();
const { t } = useTranslation('permission_management');
React.useEffect(() => {
reload();
}, [refreshTrigger, reload]);
React.useEffect(() => {
if (!loading && !error) {
showToast({ type: "success", message: t('messages.loadSuccess') });
}
if (error) {
showToast({ type: "error", message: error });
}
}, [loading, error]);
if (loading) {
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">{t('headings.loading')}</span>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('headings.currentPermissions')}</h2>
<div className="text-sm text-gray-500">
{t('table.total', { count: permissions.length })}
</div>
</div>
{permissions.length === 0 ? (
<div className="text-center py-8">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.031 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('headings.noPermissionsTitle')}</h3>
<p className="mt-1 text-sm text-gray-500">{t('headings.noPermissionsDesc')}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.name')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.description')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.status')}</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{permissions.map(p => (
<tr key={p.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{p.name}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-600 max-w-xs">
{p.description || t('table.noDescription')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
p.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{p.is_active ? t('status.active') : t('status.inactive')}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export default PermissionOverview;

View File

@ -0,0 +1,60 @@
import { useState, useEffect, useCallback } from "react";
import useAuthStore from "../../../../store/authStore";
import { fetchPermissions, createPermission } from "../api/permissionManagementApi";
import { log } from "../../../../utils/logger";
export function usePermissions() {
const accessToken = useAuthStore((s) => s.accessToken);
const [permissions, setPermissions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(() => {
setLoading(true);
setError("");
log("usePermissions: loading permissions");
fetchPermissions(accessToken)
.then((data) => {
log("usePermissions: fetched permissions", data);
setPermissions(data.permissions || []);
setError("");
})
.catch((err) => {
log("usePermissions: error loading permissions", err);
setError("Failed to load permissions: " + err.message);
setPermissions([]);
})
.finally(() => setLoading(false));
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
return { permissions, loading, error, reload: load };
}
export function useCreatePermission() {
const accessToken = useAuthStore((s) => s.accessToken);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const create = async ({ name, description, is_active, created_by }) => {
setLoading(true);
setError("");
setSuccess(false);
log("useCreatePermission: creating permission", { name, description, is_active, created_by });
try {
await createPermission({ name, description, is_active, created_by }, accessToken);
log("useCreatePermission: permission created successfully");
setSuccess(true);
} catch (err) {
log("useCreatePermission: error creating permission", err);
setError(err.message || "Failed to create permission");
}
setLoading(false);
};
return { create, loading, error, success };
}

View File

@ -0,0 +1,43 @@
import React, { useState } from "react";
import PermissionOverview from "../components/PermissionOverview";
import AddPermission from "../components/AddPermission";
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
import PageLayout from "../../../PageLayout";
import { useTranslation } from "react-i18next";
function PermissionManagement() {
const [refreshKey, setRefreshKey] = useState(0);
const { t } = useTranslation('permission_management');
return (
<PageLayout showHeader={true} showFooter={true}>
<GlobalAnimatedBackground />
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
<main className="relative z-10 flex justify-center py-2 px-1 sm:px-8 w-full">
<div
className="rounded-lg shadow-lg p-3 sm:p-8 bg-gray-100"
style={{
marginTop: "0.5%", // Match dashboard/referral management top margin
marginBottom: "2%",
width: "100%",
maxWidth: "1600px",
}}
>
<h1 className="text-2xl sm:text-4xl font-extrabold text-blue-900 mb-2 text-center tracking-tight">
{t('headings.title')}
</h1>
<p className="text-xs sm:text-base text-blue-900 text-center font-semibold mb-4 sm:mb-8">
{t('headings.subtitle')}
</p>
<div className="bg-white rounded-lg p-2 sm:p-4 shadow-lg w-full">
<AddPermission onPermissionAdded={() => setRefreshKey((k) => k + 1)} />
<PermissionOverview refreshTrigger={refreshKey} />
</div>
</div>
</main>
</div>
</PageLayout>
);
}
export default PermissionManagement;

View File

@ -0,0 +1,46 @@
import { log } from "../../../../utils/logger";
export async function fetchAdminUserFullData({ id, accessToken }) {
log("fetchAdminUserFullData called", { id, accessToken });
const res = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/admin/users/${id}/full`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
credentials: "include",
}
);
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
log("fetchAdminUserFullData error response:", errData);
throw new Error(errData.message || `HTTP ${res.status}`);
}
const json = await res.json();
log("fetchAdminUserFullData success, response:", json);
return json;
}
export async function fetchAdminUserDocuments({ id, accessToken }) {
log("fetchAdminUserDocuments called", { id, accessToken });
const res = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/users/${id}/documents`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
credentials: "include",
}
);
if (!res.ok) {
log("fetchAdminUserDocuments failed with status:", res.status);
return { contracts: [], idDocuments: [] };
}
const json = await res.json();
log("fetchAdminUserDocuments success, response:", json);
return json;
}

View File

@ -0,0 +1,104 @@
import { authFetch } from "../../../../utils/authFetch";
import { log } from "../../../../utils/logger";
export async function fetchUserList(accessToken) {
log("fetchUserList called", { accessToken });
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/admin/user-list`,
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
credentials: "include",
}
);
if (!res.ok) {
log("fetchUserList failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchUserList success, response:", json);
return json;
}
export async function fetchUserStats(accessToken) {
log("fetchUserStats called", { accessToken });
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/admin/user-stats`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
credentials: "include",
}
);
if (!res.ok) {
log("fetchUserStats failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchUserStats success, response:", json);
return json;
}
export async function fetchUserFull(accessToken, id) {
// Debug: log the accessToken and headers
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
};
log("[fetchUserFull] accessToken:", accessToken);
log("[fetchUserFull] Request headers:", headers);
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/users/${id}/full`,
{
method: "GET",
headers,
credentials: "include",
}
);
if (!res.ok) {
log("fetchUserFull failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchUserFull success, response:", json);
return json;
}
export async function deleteAdminUser({ id, accessToken }) {
log("[deleteAdminUser] Sending DELETE request", { id, accessToken });
const url = `${import.meta.env.VITE_API_BASE_URL}/api/admin/user/${id}`;
log("[deleteAdminUser] Request URL:", url);
const res = await fetch(
url,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
credentials: "include",
}
);
log("[deleteAdminUser] Response status:", res.status);
let responseBody = null;
try {
responseBody = await res.clone().json();
log("[deleteAdminUser] Response body:", responseBody);
} catch (e) {
log("[deleteAdminUser] Response body not JSON or empty");
}
if (!res.ok) {
let msg = "Failed to delete user";
if (responseBody && responseBody.message) {
msg = responseBody.message;
}
log("[deleteAdminUser] Error:", msg);
throw new Error(msg);
}
log("[deleteAdminUser] Delete successful for user", id);
return true;
}

View File

@ -0,0 +1,120 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { showToast } from "../../../toast/toastUtils"; // adjust import if needed
export default function AdminUserActionsSection({
userId, // <-- Ensure userId is passed as prop
onEditPermissions,
onShowStatistics,
onExportUserData,
onShowLogs
}) {
const { t } = useTranslation('user_management');
const [resetLoading, setResetLoading] = useState(false);
// Handler for password reset
const handlePasswordReset = async () => {
if (!userId) {
showToast({ type: "error", tKey: "toast:passwordResetGenericError" });
return;
}
const token = sessionStorage.getItem("accessToken"); // <-- use sessionStorage
console.log("JWT accessToken for request:", token);
if (!token) {
showToast({ type: "error", tKey: "toast:unauthorized" });
return;
}
setResetLoading(true);
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL;
const requestOptions = {
method: "POST",
credentials: "include",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
// body: JSON.stringify({}) // if you need to send a body, add here
};
console.log("Sending password reset request:", {
url: `${baseUrl}/api/admin/send-password-reset/${userId}`,
...requestOptions
});
const res = await fetch(`${baseUrl}/api/admin/send-password-reset/${userId}`, requestOptions);
console.log("Password reset response:", res); // <-- Add this line
if (res.ok) {
showToast({ type: "success", tKey: "toast:passwordResetSuccess" });
} else {
// Handle specific errors
if (res.status === 401) {
showToast({ type: "error", tKey: "toast:unauthorized" });
} else if (res.status === 404) {
showToast({ type: "error", tKey: "toast:userNotFound" });
} else if (res.status === 429) {
showToast({ type: "error", tKey: "toast:passwordResetRateLimited" });
} else if (res.status === 403) {
showToast({ type: "error", tKey: "toast:forbidden" });
} else {
showToast({ type: "error", tKey: "toast:passwordResetGenericError" });
}
}
} catch (err) {
showToast({ type: "error", tKey: "toast:networkError" });
} finally {
setResetLoading(false);
}
};
return (
<div className="bg-gray-50 border border-gray-200 rounded-xl shadow p-6 flex flex-col gap-4 mt-8">
<h2 className="text-lg font-semibold text-gray-800 mb-2 flex items-center gap-2">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
{t('headings.actions')}
</h2>
<div className="flex flex-wrap gap-3">
<span style={{ color: 'gray' }}>working</span>
<button
className="px-4 py-2 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 border border-blue-200 shadow transition"
onClick={handlePasswordReset}
type="button"
disabled={resetLoading}
>
{resetLoading ? t('buttons.sendingPasswordReset') : t('buttons.sendPasswordReset')}
</button>
<button
className="px-4 py-2 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 border border-purple-200 shadow transition"
onClick={onEditPermissions}
type="button"
>
{t('buttons.editPermissions')}
</button>
<span style={{ color: 'gray' }}>in development</span>
<button
className="px-4 py-2 bg-green-100 text-green-700 rounded hover:bg-green-200 border border-green-200 shadow transition"
onClick={onShowStatistics}
type="button"
>
{t('buttons.statistic')}
</button>
<button
className="px-4 py-2 bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 border border-yellow-200 shadow transition"
onClick={onExportUserData}
type="button"
>
{t('buttons.exportUserData')}
</button>
<button
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 border border-gray-300 shadow transition"
onClick={onShowLogs}
type="button"
>
{t('buttons.logs')}
</button>
</div>
</div>
);
}
// Tip: For production, use an environment variable for the backend URL instead of hardcoding it.

View File

@ -0,0 +1,406 @@
import React, { useMemo, useEffect, useState } from "react";
import { useUserList, useDeleteAdminUser } from "../hooks/useUserManagement";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import DeleteConfirmationModal from "./DeleteConfirmationModal";
function AdminUserList({ pageSize = 10, filters = {}, onPageSizeChange, refreshKey, onRefresh }) {
const { users: allUsers, loading, error, reload } = useUserList();
const { deleteUser, loading: deleting, error: deleteError } = useDeleteAdminUser();
const [page, setPage] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const navigate = useNavigate();
const { t } = useTranslation('user_management');
// Reset to page 1 when filters or pageSize change
useEffect(() => {
setPage(1);
}, [filters, pageSize]);
// Client-side filtering
const filteredUsers = useMemo(() => {
let users = allUsers;
if (filters.search) {
const search = filters.search.toLowerCase();
users = users.filter(
(u) =>
(u.email && u.email.toLowerCase().includes(search)) ||
(u.first_name && u.first_name.toLowerCase().includes(search)) ||
(u.last_name && u.last_name.toLowerCase().includes(search)) ||
(u.company_name && u.company_name.toLowerCase().includes(search))
);
}
if (filters.user_type) {
users = users.filter((u) => u.user_type === filters.user_type);
}
if (filters.status) {
users = users.filter((u) => u.status === filters.status);
}
if (filters.role) {
users = users.filter((u) => u.role === filters.role);
}
return users;
}, [allUsers, filters]);
// Pagination
const totalUsers = filteredUsers.length;
const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize));
const pagedUsers = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredUsers.slice(start, start + pageSize);
}, [filteredUsers, page, pageSize]);
// Open modal
const openDeleteModal = (user) => {
setSelectedUser(user);
setDeleteModalOpen(true);
};
// Confirm deletion
const confirmDelete = async () => {
if (!selectedUser) return;
const success = await deleteUser(selectedUser.id);
setDeleteModalOpen(false);
setSelectedUser(null);
if (success) {
reload(); // Immediately refetch user list
if (onRefresh) onRefresh();
} else if (deleteError) {
alert(deleteError);
}
};
return (
<div className="bg-white overflow-hidden shadow-lg rounded-xl border border-gray-100">
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
<h2 className="text-xl font-semibold text-gray-900">{t('table.allUsers')}</h2>
<p className="text-gray-600 mt-1">
{loading ? t('misc.loadingUsers') : t('table.showing', { shown: pagedUsers.length, total: totalUsers })}
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.user')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.type')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.status')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.role')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.created')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.lastLogin')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-400">
Loading...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-red-500">
{error}
</td>
</tr>
) : pagedUsers.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center">
<div className="text-gray-400">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
></path>
</svg>
<p className="text-gray-500 text-lg font-medium">
{t('table.noUsers')}
</p>
<p className="text-sm text-gray-400 mt-1">
{t('table.adjustFilters')}
</p>
</div>
</td>
</tr>
) : (
pagedUsers.map((user) => (
<tr
key={user.id}
className="hover:bg-gray-50 transition-colors duration-150"
>
<td className="px-6 py-4">
<div className="flex items-center">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center mr-4 shadow-lg">
<span className="text-sm font-bold text-white">
{/* Show initials for personal/company */}
{user.user_type === "personal"
? `${(user.first_name?.[0] || user.email?.[0] || "").toUpperCase()}${(user.last_name?.[0] || user.email?.split("@")[0]?.split(".")[1]?.[0] || "").toUpperCase()}`
: (user.company_name
? user.company_name
.split(" ")
.map(w => w[0])
.join("")
.slice(0, 2)
.toUpperCase()
: (user.email?.[0] || "").toUpperCase())
}
</span>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">
{user.user_type === "personal"
? `${user.first_name ?? ""} ${user.last_name ?? ""}`
: user.company_name}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
user.user_type === "company"
? "bg-purple-100 text-purple-800 border border-purple-200"
: "bg-blue-100 text-blue-800 border border-blue-200"
}`}
>
<div
className={`w-2 h-2 rounded-full mr-2 ${
user.user_type === "company"
? "bg-purple-400"
: "bg-blue-400"
}`}
></div>
{user.user_type === "company" ? t('filters.company') : t('filters.personal')}
</span>
</td>
<td className="px-6 py-4">
{user.status === "active" && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
<div className="w-2 h-2 bg-green-400 rounded-full mr-2"></div>
{t('filters.active')}
</span>
)}
{user.status === "pending" && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
<div className="w-2 h-2 bg-yellow-400 rounded-full mr-2"></div>
{t('filters.pending')}
</span>
)}
{user.status === "suspended" && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-200">
<div className="w-2 h-2 bg-red-400 rounded-full mr-2"></div>
{t('filters.suspended')}
</span>
)}
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
user.role === "super_admin"
? "bg-red-100 text-red-800 border border-red-200"
: user.role === "admin"
? "bg-indigo-100 text-indigo-800 border border-indigo-200"
: "bg-gray-100 text-gray-800 border border-gray-200"
}`}
>
<div
className={`w-2 h-2 rounded-full mr-2 ${
user.role === "super_admin"
? "bg-red-400"
: user.role === "admin"
? "bg-indigo-400"
: "bg-gray-400"
}`}
></div>
{t(`filters.${user.role}`)}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-900 font-medium">
{user.created_at?.slice(0, 10) ?? "-"}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
{user.last_login_at ? (
<span className="font-medium">
{user.last_login_at.slice(0, 10)}
</span>
) : (
<span className="text-gray-400 italic">{t('table.never')}</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex space-x-2">
<button
className="inline-flex items-center px-3 py-1.5 border border-blue-300 text-xs font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 hover:border-blue-400 transition-colors duration-200"
onClick={() =>
navigate(`/admin/user-management/view/${user.id}`)
}
>
<svg
className="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
</svg>
{t('buttons.view')}
</button>
<button
className="inline-flex items-center px-3 py-1.5 border border-yellow-300 text-xs font-medium rounded-md text-yellow-700 bg-yellow-50 hover:bg-yellow-100 hover:border-yellow-400 transition-colors duration-200"
onClick={() =>
navigate(`/admin/user-management/profile/${user.id}`)
}
>
<svg
className="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15.232 5.232l3.536 3.536M9 13l6-6m2 2l-6 6m2 2H7a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v6z"
/>
</svg>
{t('buttons.edit')}
</button>
<button
className="inline-flex items-center px-3 py-1.5 border border-red-300 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 hover:border-red-400 transition-colors duration-200"
onClick={() => openDeleteModal(user)}
disabled={deleting}
>
<svg
className="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{deleting && selectedUser?.id === user.id ? t('misc.deleting') : t('buttons.delete')}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-700">
<span className="font-medium">{t('table.pageXofY', { page, pages: totalPages })}</span>
<span className="text-gray-500"> ({t('table.totalUsers', { total: totalUsers })})</span>
</div>
<div className="flex space-x-2">
<button
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md ${
page === 1
? "text-gray-400 bg-white cursor-not-allowed"
: "text-gray-700 bg-white hover:bg-gray-100"
}`}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 19l-7-7 7-7"
></path>
</svg>
{t('table.previous')}
</button>
<button
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md ${
page === totalPages
? "text-gray-400 bg-white cursor-not-allowed"
: "text-gray-700 bg-white hover:bg-gray-100"
}`}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
{t('table.next')}
<svg
className="w-4 h-4 ml-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
<DeleteConfirmationModal
open={deleteModalOpen}
onConfirm={confirmDelete}
onCancel={() => { setDeleteModalOpen(false); setSelectedUser(null); }}
user={selectedUser}
/>
</div>
);
}
AdminUserList.defaultProps = {
onRefresh: () => {},
};
export default AdminUserList;

View File

@ -0,0 +1,102 @@
import React from "react";
import { useTranslation } from "react-i18next";
function AdminUserListFilterForm({ filters = {}, setFilters, pageSize, setPageSize }) {
const handleChange = (e) => {
setFilters({ ...filters, [e.target.name]: e.target.value });
};
const handleSubmit = (e) => {
e.preventDefault();
setFilters({ ...filters }); // Trigger filter (if needed)
};
const { t } = useTranslation('user_management');
return (
<div className="bg-white overflow-hidden shadow-lg rounded-xl border border-gray-100 mb-8">
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
<h2 className="text-xl font-semibold text-gray-900">{t('headings.filterTitle')}</h2>
<p className="text-gray-600 mt-1">{t('headings.filterSubtitle')}</p>
</div>
<div className="p-6">
<form
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"
onSubmit={handleSubmit}
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<input
name="search"
value={filters.search || ""}
onChange={handleChange}
type="text"
placeholder={t('filters.searchPlaceholder')}
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.userType')}</label>
<select
name="user_type"
value={filters.user_type || ""}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
>
<option value="">{t('filters.allTypes')}</option>
<option value="personal">{t('filters.personal')}</option>
<option value="company">{t('filters.company')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.status')}</label>
<select
name="status"
value={filters.status || ""}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
>
<option value="">{t('filters.allStatus')}</option>
<option value="active">{t('filters.active')}</option>
<option value="pending">{t('filters.pending')}</option>
<option value="suspended">{t('filters.suspended')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.role')}</label>
<select
name="role"
value={filters.role || ""}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
>
<option value="">{t('filters.allRoles')}</option>
<option value="user">{t('filters.user')}</option>
<option value="admin">{t('filters.admin')}</option>
<option value="super_admin">{t('filters.super_admin')}</option>
</select>
</div>
<div className="flex items-end">
<button
type="submit"
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white px-4 py-2 rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
<svg className="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
{t('buttons.filter')}
</button>
</div>
</form>
</div>
</div>
);
}
export default AdminUserListFilterForm;

View File

@ -0,0 +1,102 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import PageLayout from "../../../PageLayout";
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
import useAdminUserProfile from "../hooks/useAdminUserProfile";
import AdminUserProfileCard from "./AdminUserProfileCard";
import AdminUserMediaSection from "./AdminUserMediaSection";
import AdminUserPermissionsSection from "./AdminUserPermissionsSection";
import EnlargeMediaModal from "./EnlargeMediaModal";
function AdminUserListView() {
const navigate = useNavigate();
const {
loading,
error,
user,
profile,
permissions,
documents,
enlargeDoc,
setEnlargeDoc,
enlargeDocNavigation,
} = useAdminUserProfile();
// Log backend data for debugging
React.useEffect(() => {
if (user) {
console.log("AdminUserListView: user data", user);
}
if (profile) {
console.log("AdminUserListView: profile data", profile);
}
}, [user, profile]);
if (loading) {
return (
<PageLayout showHeader showFooter>
<GlobalAnimatedBackground />
<div className="flex justify-center items-center min-h-screen">
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
<span className="text-blue-600 font-bold text-xl">Loading user...</span>
</div>
</div>
</PageLayout>
);
}
if (error || !user) {
return (
<PageLayout showHeader showFooter>
<GlobalAnimatedBackground />
<div className="flex justify-center items-center min-h-screen">
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
<span className="text-red-600 font-bold text-xl">{error || "User not found"}</span>
<button
className="mt-6 px-4 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700"
onClick={() => navigate(-1)}
>
Go Back
</button>
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout showHeader showFooter>
<GlobalAnimatedBackground />
{enlargeDoc && (
<EnlargeMediaModal
doc={enlargeDoc}
onClose={() => setEnlargeDoc(null)}
navigation={enlargeDocNavigation}
/>
)}
<div className="relative z-10 flex justify-center py-6 sm:py-12 px-1 sm:px-8 w-full">
<div
className="bg-white rounded-2xl shadow-2xl p-2 sm:p-8 w-full"
style={{ maxWidth: "1600px", marginTop: "0.5%" }}
>
<AdminUserProfileCard
user={user}
profile={profile}
infoTitle={
(user?.userType || user?.user_type) === "company"
? "Company Info"
: "Profile Info"
}
/>
<AdminUserMediaSection
documents={documents}
setEnlargeDoc={setEnlargeDoc}
/>
<AdminUserPermissionsSection permissions={permissions} />
</div>
</div>
</PageLayout>
);
}
export default AdminUserListView;

View File

@ -0,0 +1,29 @@
import React, { useState } from "react";
import AdminUserList from "./AdminUserList";
import AdminUserListFilterForm from "./AdminUserListFilterForm";
function AdminUserManagementPage() {
const [filters, setFilters] = useState({});
const [pageSize, setPageSize] = useState(10);
const [refreshKey, setRefreshKey] = useState(0);
return (
<>
<AdminUserListFilterForm
filters={filters}
setFilters={setFilters}
pageSize={pageSize}
setPageSize={setPageSize}
onRefresh={() => setRefreshKey((k) => k + 1)}
/>
<AdminUserList
filters={filters}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refreshKey={refreshKey}
/>
</>
);
}
export default AdminUserManagementPage;

View File

@ -0,0 +1,172 @@
import React, { useRef } from "react";
import { useTranslation } from "react-i18next";
export default function AdminUserMediaSection({ documents, setEnlargeDoc }) {
// Helper to truncate filename with ellipsis
const truncate = (str, max = 24) =>
str && str.length > max ? str.slice(0, max - 3) + "..." : str;
// Use refs to avoid rendering <a> elements that trigger download on mount
const downloadRefs = useRef({});
const { t } = useTranslation('user_management');
return (
<div className="mb-10">
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<svg className="w-6 h-6 text-blue-400 mr-1" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M3 17l6-6 4 4 5-5" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
{t('headings.media')}
</h2>
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 flex flex-col gap-4">
{/* ID Documents */}
<div>
<div className="font-medium text-gray-700 mb-2 flex items-center gap-2">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
{t('media.idDocuments')}
</div>
{documents.idDocuments.length > 0 ? (
<div className="flex flex-wrap gap-4">
{documents.idDocuments.map((doc) => (
<div
key={
(doc.user_id_document_id
? `${doc.user_id_document_id}-${doc.side || "unknown"}`
: doc.object_storage_id)
}
className="flex items-center gap-3 bg-white rounded shadow px-3 py-2"
style={{ minWidth: 0, maxWidth: "100%" }}
>
{/* Preview */}
{doc.signedUrl && doc.original_filename && (
/\.(jpe?g|png|gif|bmp|webp)$/i.test(doc.original_filename) ? (
<img
src={doc.signedUrl}
alt={doc.original_filename}
className="w-16 h-16 object-cover rounded border"
style={{ minWidth: 48, minHeight: 48 }}
/>
) : /\.pdf$/i.test(doc.original_filename) ? (
<embed
src={doc.signedUrl}
type="application/pdf"
className="w-16 h-16 rounded border bg-gray-50"
style={{ minWidth: 48, minHeight: 48 }}
/>
) : (
<span className="w-16 h-16 flex items-center justify-center bg-gray-100 text-gray-400 rounded border text-xs">
No Preview
</span>
)
)}
<span className="text-xs font-semibold text-gray-700 capitalize">
{doc.side ? t('media.idSide', { side: doc.side }) : t('media.idGeneric')}
</span>
<span
className="text-gray-500 text-xs max-w-[100px] sm:max-w-[180px] truncate cursor-pointer"
title={doc.original_filename}
style={{
display: "inline-block",
verticalAlign: "middle",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 120,
}}
>
{truncate(doc.original_filename, 24)}
</span>
<button
className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
onClick={() => setEnlargeDoc(doc)}
>
{t('buttons.enlarge')}
</button>
{/* Only show download for images */}
{/\.(jpe?g|png|gif|bmp|webp)$/i.test(doc.original_filename) && (
<button
className="ml-1 px-2 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
onClick={() => {
const a = document.createElement("a");
a.href = doc.signedUrl;
a.download = doc.original_filename;
document.body.appendChild(a);
a.click();
a.remove();
}}
type="button"
>
{t('buttons.download')}
</button>
)}
</div>
))}
</div>
) : (
<span className="text-gray-400 text-xs">{t('media.noIdDocuments')}</span>
)}
</div>
{/* Contracts */}
<div>
<div className="font-medium text-gray-700 mb-2 flex items-center gap-2">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M7 7v10a2 2 0 002 2h6a2 2 0 002-2V7" stroke="currentColor" strokeWidth="2" fill="none"/>
<rect x="7" y="3" width="10" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
{t('media.contracts')}
</div>
{documents.contracts.length > 0 ? (
<div className="flex flex-wrap gap-4">
{documents.contracts.map((contract) => (
<div key={contract.id || contract.object_storage_id} className="flex items-center gap-3 bg-white rounded shadow px-3 py-2" style={{ minWidth: 0, maxWidth: "100%" }}>
{/* PDF Preview */}
{contract.signedUrl && contract.original_filename && (
/\.pdf$/i.test(contract.original_filename) ? (
<embed
src={contract.signedUrl}
type="application/pdf"
className="w-16 h-16 rounded border bg-gray-50"
style={{ minWidth: 48, minHeight: 48 }}
/>
) : (
<span className="w-16 h-16 flex items-center justify-center bg-gray-100 text-gray-400 rounded border text-xs">
No Preview
</span>
)
)}
<span
className="text-gray-500 text-xs max-w-[100px] sm:max-w-[180px] truncate cursor-pointer"
title={contract.original_filename}
style={{
display: "inline-block",
verticalAlign: "middle",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 120,
}}
>
{truncate(contract.original_filename, 24)}
</span>
<button
className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
onClick={() => setEnlargeDoc(contract)}
>
{t('buttons.enlarge')}
</button>
{/* No download button for contracts (PDFs) */}
</div>
))}
</div>
) : (
<span className="text-gray-400 text-xs">{t('media.noContracts')}</span>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
import React from "react";
import { useTranslation } from "react-i18next";
export default function AdminUserPermissionsSection({ permissions }) {
const { t } = useTranslation('user_management');
return (
<div className="mb-10">
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<svg className="w-6 h-6 text-green-400 mr-1" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4" stroke="currentColor" strokeWidth="2" fill="none"/>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
{t('headings.permissions')}
</h2>
<div className="flex flex-wrap gap-2 bg-green-50 border border-green-100 rounded-lg p-4">
{Array.isArray(permissions) && permissions.length > 0 ? (
permissions.map((perm) => (
<span
key={perm.id || perm.name || perm}
className="inline-block bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-medium border border-green-200"
>
{perm.name || perm}
</span>
))
) : (
<span className="text-gray-400 text-sm">{t('permissions.none')}</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,352 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function AdminUserProfileCard({ user, profile, permissions = [], infoTitle = "Profile Info" }) {
const navigate = useNavigate();
const { t } = useTranslation('user_management');
if (!user) return null;
const rows = [
['profile.fullName', user.fullName || `${user.firstName || ''} ${user.lastName || ''}`.trim()],
['profile.phone', user.phone],
['profile.contactPerson', user.contactPersonName],
['profile.registrationNumber', user.registrationNumber],
['profile.branch', user.branch],
['profile.accountHolderName', user.accountHolderName],
['profile.iban', user.iban]
].filter(r => r[1]);
return (
<div className="mb-6 w-full">
{/* Back button at top left */}
<div className="flex gap-2 mb-4">
<button
className="px-4 py-2 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 border border-blue-200 shadow transition"
type="button"
onClick={() => navigate("/admin/user-management")}
>
{t('buttons.back')}
</button>
</div>
{/* Avatar/Initials, Name, Email, Type */}
<div className="flex flex-col items-center mb-6">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white text-2xl font-bold shadow mb-3">
{user.user_type === "personal"
? `${(profile?.first_name?.[0] || user.email?.[0] || "").toUpperCase()}${(profile?.last_name?.[0] || user.email?.split("@")[0]?.split(".")[1]?.[0] || "").toUpperCase()}`
: (profile?.company_name
? profile.company_name
.split(" ")
.map(w => w[0])
.join("")
.slice(0, 2)
.toUpperCase()
: (user.email?.[0] || "").toUpperCase())
}
</div>
<div className="text-base sm:text-2xl font-bold text-blue-900 text-center">
{user.user_type === "company"
? profile?.company_name || user.email
: `${profile?.first_name || ""} ${profile?.last_name || ""}`.trim() || user.email}
</div>
<p className="text-gray-500 break-all text-center text-sm sm:text-lg">{user.email}</p>
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs font-medium ${
user.user_type === "company"
? "bg-purple-100 text-purple-800 border border-purple-200"
: "bg-blue-100 text-blue-800 border border-blue-200"
}`}>
{user.user_type === "company" ? t('filters.company') : t('filters.personal')}
</span>
</div>
{/* Add space between header and info cards */}
<div className="mb-8"></div>
{/* User Info & Profile Info as cards with icons */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch">
{/* User Info Card */}
<div className="bg-blue-50 border border-blue-100 rounded-xl shadow p-6 flex flex-col gap-2 h-full">
<div className="flex items-center gap-2 mb-3">
{/* User icon */}
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
{/* Left card: always Profile Info now (personal or company) */}
<h2 className="text-xl font-semibold text-blue-900">
{t('headings.profileInfo')}
</h2>
</div>
<ul className="text-gray-700 text-base">
{user.user_type === "company" ? (
<>
<li className="mb-2 flex items-center gap-2">
{/* Contact Person Name icon */}
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.contactPersonName')}:</span> {profile?.contact_person_name}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Contact Person Phone icon */}
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.contactPersonPhone')}:</span> {profile?.contact_person_phone}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Created At icon */}
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.createdAt')}:</span> {user.created_at ? new Date(user.created_at).toLocaleString() : "-"}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Updated At icon */}
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.updatedAt')}:</span> {user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Last Login icon */}
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 8v4l3 3" stroke="currentColor" strokeWidth="2" fill="none"/>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.lastLogin')}:</span> {user.last_login_at ? new Date(user.last_login_at).toLocaleString() : "-"}
</li>
</>
) : (
<>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M16 12a4 4 0 01-8 0V8a4 4 0 018 0v4z" stroke="currentColor" strokeWidth="2" fill="none"/>
<circle cx="12" cy="4" r="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.email')}:</span> {user.email}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
<span className="font-medium">{t('profile.userType')}:</span> {user.user_type}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 17v-6m0 0V7m0 4h4m-4 0H8" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.role')}:</span> {user.role}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">Status:</span> {user.status}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.createdAt')}:</span> {user.created_at ? new Date(user.created_at).toLocaleString() : "-"}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.updatedAt')}:</span> {user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 8v4l3 3" stroke="currentColor" strokeWidth="2" fill="none"/>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.lastLogin')}:</span> {user.last_login_at ? new Date(user.last_login_at).toLocaleString() : "-"}
</li>
</>
)}
</ul>
</div>
{/* Company Info / Profile Info Card */}
<div className="bg-purple-50 border border-purple-100 rounded-xl shadow p-6 flex flex-col gap-2 mb-6 w-full h-full">
<div className="flex items-center gap-2 mb-3">
{/* Profile icon */}
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<h2 className="text-xl font-semibold text-purple-900">
{(user.user_type === "company")
? t('headings.companyInfo')
: t('headings.accountInfo') /* Right card becomes Account Info for personal */}
</h2>
</div>
<ul className="text-gray-700 text-base">
{user.user_type === "company" ? (
<>
<li className="mb-2 flex items-center gap-2">
{/* Company Name icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyName')}:</span> {profile?.company_name}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company Email icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8" stroke="currentColor" strokeWidth="2" fill="none"/>
<rect x="3" y="8" width="18" height="8" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyEmail')}:</span> {user?.email}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company Phone icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyPhone')}:</span> {profile?.phone}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company Address icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M3 10v10a1 1 0 001 1h16a1 1 0 001-1V10" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M7 10V7a5 5 0 0110 0v3" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyAddress')}:</span> {profile?.address}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company Postal Code icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyPostalCode')}:</span> {profile?.zip_code}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company City icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyCity')}:</span> {profile?.city}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company Country icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyCountry')}:</span> {profile?.country}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company Business Type icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyBusinessType')}:</span> {profile?.branch}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company Number of Employees icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
<text x="12" y="16" textAnchor="middle" fontSize="10" fill="currentColor">#</text>
</svg>
<span className="font-medium">{t('profile.companyEmployees')}:</span> {profile?.number_of_employees}
</li>
<li className="mb-2 flex items-center gap-2">
{/* Company Registration Number icon */}
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
<span className="font-medium">{t('profile.companyRegistrationNumber')}:</span> {profile?.registration_number}
</li>
{/* Removed Account Holder Name and IBAN from here */}
</>
) : (
<>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.firstName')}:</span> {profile?.first_name}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.lastName')}:</span> {profile?.last_name}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.dateOfBirth')}:</span> {profile?.date_of_birth}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.nationality')}:</span> {profile?.nationality}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M3 10v10a1 1 0 001 1h16a1 1 0 001-1V10" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M7 10V7a5 5 0 0110 0v3" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.address')}:</span> {profile?.address}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.city')}:</span> {profile?.city}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.country')}:</span> {profile?.country}
</li>
<li className="mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.phone')}:</span> {profile?.phone}
</li>
</>
)}
</ul>
</div>
{/* Bank Details Card for company users - full width in grid */}
{user.user_type === "company" && (
<div className="bg-green-50 border border-green-100 rounded-xl shadow p-6 flex flex-col gap-2 mb-6 w-full mt-6 md:col-span-2">
<div className="flex items-center gap-2 mb-3">
{/* Bank icon */}
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="3" y="10" width="18" height="11" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M3 10l9-7 9 7" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<h2 className="text-xl font-semibold text-green-900">{t('headings.bankDetails')}</h2>
</div>
<ul className="text-gray-700 text-base">
<li className="mb-2 flex items-center gap-2">
{/* Account Holder Name icon */}
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<span className="font-medium">{t('profile.companyAccountHolderName')}:</span> {profile?.account_holder_name}
</li>
<li className="mb-2 flex items-center gap-2">
{/* IBAN icon */}
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
<span className="font-medium">{t('profile.companyIban')}:</span> {user?.iban || profile?.iban}
</li>
</ul>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import React from "react";
import { useTranslation } from "react-i18next";
export default function AdminUserStatistic({ stats }) {
const { t } = useTranslation('user_management');
if (!stats) return null;
const Box = ({ label, value }) => (
<div className="bg-blue-50 rounded-lg p-4 text-center">
<div className="text-xs uppercase tracking-wide text-blue-600 font-medium">{label}</div>
<div className="text-2xl font-bold text-blue-900 mt-1">{value ?? 0}</div>
</div>
);
return (
<div className="space-y-3">
<h4 className="font-semibold text-gray-800">{t('headings.stats')}</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<Box label={t('stats.totalUsers')} value={stats.totalUsers} />
<Box label={t('stats.adminUsers')} value={stats.adminUsers} />
<Box label={t('stats.activeUsers')} value={stats.activeUsers} />
<Box label={t('stats.personalUsers')} value={stats.personalUsers} />
<Box label={t('stats.companyUsers')} value={stats.companyUsers} />
<Box label={t('stats.verificationPending')} value={stats.verificationPending} />
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import React from "react";
function AdminUserStatus({ stats }) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{/* Total Users */}
<div className="bg-white overflow-hidden shadow rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Users</p>
<p className="text-3xl font-bold text-gray-900">{stats?.total_users ?? 0}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
</div>
</div>
</div>
{/* Admin Users */}
<div className="bg-white overflow-hidden shadow rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Admin Users</p>
<p className="text-3xl font-bold text-indigo-600">{stats?.admin_users ?? 0}</p>
</div>
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
</div>
</div>
</div>
{/* Verification Pending */}
<a href="/admin/verify-users" className="block">
<div className="bg-white overflow-hidden shadow rounded-lg p-6 hover:bg-orange-50 transition cursor-pointer">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Verification Pending</p>
<p className="text-3xl font-bold text-orange-600">{stats?.verification_pending_users ?? 0}</p>
<p className="text-xs text-orange-500 mt-1 underline">Click here to verify users</p>
</div>
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01"/>
</svg>
</div>
</div>
</div>
</a>
</div>
);
}
export default AdminUserStatus;

View File

@ -0,0 +1,41 @@
import React from "react";
import { useTranslation } from "react-i18next";
function DeleteConfirmationModal({ open, onConfirm, onCancel, user }) {
const { t } = useTranslation("user_management");
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<h2 className="text-lg font-bold mb-2 text-red-700">
{t("deleteModal.title", "Delete User")}
</h2>
<p className="mb-4">
{t(
"deleteModal.confirmText",
"Are you sure you want to permanently delete this user and all related data?"
)}
</p>
<div className="mb-4">
<span className="font-semibold">{user?.email}</span>
</div>
<div className="flex justify-end space-x-2">
<button
className="px-4 py-2 rounded bg-gray-200 text-gray-700 font-medium"
onClick={onCancel}
>
{t("deleteModal.cancel", "Cancel")}
</button>
<button
className="px-4 py-2 rounded bg-red-600 text-white font-bold"
onClick={onConfirm}
>
{t("deleteModal.delete", "Delete")}
</button>
</div>
</div>
</div>
);
}
export default DeleteConfirmationModal;

View File

@ -0,0 +1,241 @@
import React from "react";
import { useTranslation } from "react-i18next";
// Design preserved from original (fade, positioning, arrows, preview).
export default function EnlargeMediaModal({ doc, onClose, navigation }) {
const { t } = useTranslation('user_management');
const { type, arr, idx, set } = navigation || {};
const [visible, setVisible] = React.useState(false);
// Fade in on mount
React.useEffect(() => { setVisible(true); }, []);
// Close with fade-out
const handleClose = React.useCallback(() => {
setVisible(false);
setTimeout(() => { onClose && onClose(); }, 200);
}, [onClose]);
// Log
React.useEffect(() => {
console.log("[EnlargeMediaModal] OPENED");
console.log("[EnlargeMediaModal] doc:", doc);
console.log("[EnlargeMediaModal] navigation.type:", type);
console.log("[EnlargeMediaModal] navigation.idx:", idx);
console.log("[EnlargeMediaModal] navigation.arr:", arr);
}, [doc, type, idx, arr]);
// Keyboard navigation (optional)
React.useEffect(() => {
const key = (e) => {
if (!visible) return;
if (e.key === 'Escape') handleClose();
if (type === "id-image" && arr && arr.length > 1) {
if (e.key === 'ArrowLeft' && idx > 0) handlePrev();
if (e.key === 'ArrowRight' && idx < arr.length - 1) handleNext();
}
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [visible, idx, arr, type, handleClose]);
const handlePrev = () => {
if (arr && idx > 0) {
set(arr[idx - 1]);
}
};
const handleNext = () => {
if (arr && idx < arr.length - 1) {
set(arr[idx + 1]);
}
};
// Dimension constraints
const modalMaxWidth = "90vw";
const modalMaxHeight = "92vh";
const modalMinWidth = 320;
const modalMinHeight = 200;
// Safe guard
if (!doc) {
return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
style={{ background: "rgba(120,120,130,0.85)" }}
onClick={handleClose}
>
<div
className={`relative bg-white rounded-lg shadow-xl p-6 text-center text-sm text-gray-600 ${visible ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
onClick={e => e.stopPropagation()}
>
{t('media.noMedia','No media available')}
<button
onClick={handleClose}
className="mt-4 px-4 py-2 text-xs bg-gray-200 rounded hover:bg-gray-300"
>
{t('buttons.close','Close')}
</button>
</div>
</div>
);
}
const filename = doc.original_filename || doc.filename || doc.name || '';
const isImage = filename ? /\.(jpe?g|png|gif|bmp|webp)$/i.test(filename) : false;
const isPdf = filename ? /\.pdf$/i.test(filename) : false;
return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
style={{
background: "rgba(120,120,130,0.85)",
transition: "background 0.2s",
}}
onClick={() => handleClose()}
>
<div
className={`relative bg-white rounded-lg shadow-xl p-4 flex flex-col items-center transition-all duration-200 ${visible ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
onClick={e => e.stopPropagation()}
style={{
width: "100%",
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
minHeight: modalMinHeight,
padding: 0,
boxSizing: "border-box",
overflow: "hidden",
}}
>
{/* Close */}
<button
className="absolute top-2 right-2 flex items-center justify-center w-9 h-9 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-600 hover:text-red-500 text-xl font-bold shadow transition"
onClick={(e) => { e.stopPropagation(); handleClose(); }}
aria-label={t('buttons.close','Close')}
tabIndex={0}
style={{ zIndex: 10 }}
>
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="none">
<path d="M6 6l8 8M6 14L14 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
</button>
{/* Arrows (ID images only) */}
{type === "id-image" && arr && arr.length > 1 && (
<>
{idx > 0 && (
<button
className="absolute left-2 top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-full w-9 h-9 flex items-center justify-center text-2xl text-gray-600 shadow"
onClick={handlePrev}
aria-label="Previous"
tabIndex={0}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</button>
)}
{idx < arr.length - 1 && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-full w-9 h-9 flex items-center justify-center text-2xl text-gray-600 shadow"
onClick={handleNext}
aria-label="Next"
tabIndex={0}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</button>
)}
</>
)}
{/* Header for ID */}
{(type === "id-image" || type === "id-single") && (
<div className="mb-1 text-base font-bold text-blue-700 text-center w-full">
{doc.side
? `${doc.side.charAt(0).toUpperCase() + doc.side.slice(1)} ${t('media.idSide','of ID')}`
: t('media.idDocument','ID Document')}
{doc.id_type ? ` (${doc.id_type})` : ""}
</div>
)}
{/* Filename */}
<div className="mb-2 text-gray-700 font-semibold text-center px-4 break-all">
{filename}
</div>
{/* Preview container */}
<div
className="flex items-center justify-center w-full"
style={{
maxWidth: "100%",
maxHeight: "70vh",
minHeight: 120,
minWidth: 0,
overflow: "auto",
marginBottom: 24,
}}
>
{isImage ? (
<img
src={doc.signedUrl}
alt={filename}
className="max-h-[60vh] max-w-[80vw] rounded border"
style={{ objectFit: "contain" }}
onLoad={() => console.log("[EnlargeMediaModal] Image loaded:", filename)}
/>
) : isPdf ? (
<iframe
src={doc.signedUrl}
title={filename}
className="rounded border bg-gray-50"
style={{
width: "80vw",
height: "65vh",
minWidth: 0,
minHeight: 0,
maxWidth: "100%",
maxHeight: "100%",
border: "none",
background: "#f9fafb"
}}
allowFullScreen
/>
) : (
<span className="text-gray-400 text-lg">
{t('media.noMedia','No preview available')}
</span>
)}
</div>
{/* Download button (only for images like original) */}
{isImage && (
<button
className="mt-0 mb-4 px-4 py-2 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
onClick={() => {
const a = document.createElement("a");
a.href = doc.signedUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}}
type="button"
>
{t('buttons.download','Download')}
</button>
)}
</div>
{/* Overlay during fade-out to block clicks (same approach) */}
{!visible && (
<div className="fixed inset-0 z-50" style={{ pointerEvents: "all" }} />
)}
<style>{`
.fade-modal { transition: opacity 0.2s; }
`}</style>
</div>
);
}

View File

@ -0,0 +1,201 @@
import React, { useEffect, useState } from "react";
import axios from "axios";
import useAuthStore from "../../../../store/authStore";
const PermissionModal = ({ open, userId, onClose, onSuccess }) => {
const [userPermissions, setUserPermissions] = useState([]);
const [allPermissions, setAllPermissions] = useState([]);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [selectedPermIds, setSelectedPermIds] = useState([]);
const [visible, setVisible] = useState(false);
const [saving, setSaving] = useState(false);
const accessToken = useAuthStore((s) => s.accessToken);
useEffect(() => {
if (open) {
setVisible(true);
} else {
const timeout = setTimeout(() => setVisible(false), 300);
return () => clearTimeout(timeout);
}
}, [open]);
useEffect(() => {
if (open) {
setLoading(true);
setMessage("");
const baseUrl = import.meta.env.VITE_API_BASE_URL;
const userPermUrl = `${baseUrl}/api/auth/users/${userId}/permissions`;
const allPermUrl = `${baseUrl}/api/permissions`;
console.log("PermissionModal: Requesting user permissions from", userPermUrl);
console.log("PermissionModal: Requesting all permissions from", allPermUrl);
console.log("PermissionModal: Using access token:", accessToken);
Promise.all([
axios.get(userPermUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
}),
axios.get(allPermUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
}),
])
.then(([userRes, allRes]) => {
console.log("PermissionModal: /api/auth/users/:id/permissions response", userRes);
console.log("PermissionModal: /api/permissions response", allRes);
let perms = userRes.data;
if (
typeof perms === "string" &&
perms.startsWith("<!doctype html")
) {
setMessage(
"Fehler: Die Benutzerberechtigungen konnten nicht geladen werden. Die API liefert kein JSON."
);
setUserPermissions([]);
} else {
if (!Array.isArray(perms)) perms = Object.values(perms);
setUserPermissions(perms);
}
let allPerms = allRes.data.permissions;
if (!Array.isArray(allPerms)) allPerms = [];
setAllPermissions(allPerms);
})
.catch((err) => {
console.error("PermissionModal: Error loading permissions", err);
setMessage("Fehler beim Laden der Berechtigungen");
})
.finally(() => setLoading(false));
}
}, [open, userId, accessToken]);
const userPermIds = new Set(
Array.isArray(userPermissions) ? userPermissions.map((p) => p.id) : []
);
useEffect(() => {
if (open) {
setSelectedPermIds(Array.isArray(userPermissions) ? userPermissions.map((p) => p.id) : []);
}
}, [open, userPermissions]);
const handleCheckboxChange = (permId) => {
setSelectedPermIds((prev) =>
prev.includes(permId)
? prev.filter((id) => id !== permId)
: [...prev, permId]
);
};
const handleSavePermissions = async () => {
setSaving(true);
setMessage("");
const baseUrl = import.meta.env.VITE_API_BASE_URL;
const url = `${baseUrl}/api/admin/users/${userId}/permissions`;
const selectedPermNames = allPermissions
.filter((perm) => selectedPermIds.includes(perm.id))
.map((perm) => perm.name);
try {
await axios.put(
url,
{ permissions: selectedPermNames },
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
setMessage("Berechtigungen erfolgreich gespeichert.");
if (onSuccess) onSuccess();
setUserPermissions(
allPermissions.filter((perm) => selectedPermIds.includes(perm.id))
);
} catch (err) {
setMessage("Fehler beim Speichern der Berechtigungen.");
console.error("PermissionModal: Error saving permissions", err);
} finally {
setSaving(false);
}
};
if (!visible) return null;
return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center bg-transparent backdrop-blur-sm bg-white/30
transition-opacity duration-300 ${open ? "opacity-100" : "opacity-0"}
`}
>
<div className="bg-white rounded-lg shadow-xl border border-gray-200 p-8 w-[80vw] max-w-2xl transition-all duration-300 scale-100">
<div className="mb-2 text-center">
<h1 className="text-2xl font-bold text-blue-900 mb-1">Permissions</h1>
<h2 className="text-lg font-semibold mb-6 text-gray-700">
Berechtigungen des Nutzers
</h2>
</div>
{loading && (
<div className="flex justify-center items-center mb-4">
<span className="animate-spin h-6 w-6 mr-2 border-4 border-blue-300 border-t-transparent rounded-full inline-block"></span>
<span className="text-blue-600">Laden...</span>
</div>
)}
{message && (
<p className="mb-4 text-red-600 text-center">{message}</p>
)}
<div className="mb-6 max-h-64 overflow-y-auto">
{(!Array.isArray(allPermissions) || allPermissions.length === 0) && !loading && (
<p className="text-gray-500 text-center">
Keine Berechtigungen vorhanden.
</p>
)}
<ul>
{Array.isArray(allPermissions) && allPermissions.map((perm, idx) => {
const checked = selectedPermIds.includes(perm.id);
return (
<li key={perm.id || idx} className="flex items-center mb-3 px-2 py-1 rounded bg-gray-50">
<input
type="checkbox"
checked={checked}
onChange={() => handleCheckboxChange(perm.id)}
className="mr-3 accent-blue-700 h-5 w-5"
/>
<span className={`font-medium ${checked ? "text-blue-900" : "text-gray-600"}`}>
{perm.name}
</span>
<span className="ml-2 text-xs text-gray-400">{perm.description}</span>
</li>
);
})}
</ul>
</div>
<div className="flex justify-center gap-2">
<button
type="button"
onClick={() => {
setVisible(false);
setTimeout(onClose, 300);
}}
disabled={loading || saving}
className="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded transition font-semibold"
>
Schließen
</button>
<button
type="button"
onClick={handleSavePermissions}
disabled={loading || saving}
className="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded transition font-semibold flex items-center justify-center"
>
{saving && (
<span className="animate-spin h-5 w-5 mr-2 border-4 border-white border-t-transparent rounded-full inline-block"></span>
)}
Save Permissions
</button>
</div>
</div>
</div>
);
};
export default PermissionModal;

View File

@ -0,0 +1,117 @@
import { useParams } from "react-router-dom";
import React from "react";
import useAuthStore from "../../../../store/authStore";
import {
fetchAdminUserFullData,
fetchAdminUserDocuments,
} from "../api/adminUserProfileApi";
import { log } from "../../../../utils/logger";
export default function useAdminUserProfile() {
const { id } = useParams();
const accessToken = useAuthStore((s) => s.accessToken);
const [user, setUser] = React.useState(null);
const [personalProfile, setPersonalProfile] = React.useState(null);
const [companyProfile, setCompanyProfile] = React.useState(null);
const [permissions, setPermissions] = React.useState([]);
const [documents, setDocuments] = React.useState({ contracts: [], idDocuments: [] });
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [enlargeDoc, setEnlargeDoc] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
async function fetchAll() {
setLoading(true);
setError("");
log("useAdminUserProfile: fetching user and documents", { id, accessToken });
try {
const [userData, docData] = await Promise.all([
fetchAdminUserFullData({ id, accessToken }),
fetchAdminUserDocuments({ id, accessToken }),
]);
if (cancelled) return;
log("useAdminUserProfile: fetched userData", userData);
log("useAdminUserProfile: fetched docData", docData);
setUser(userData.user);
setPersonalProfile(userData.personalProfile || null);
setCompanyProfile(userData.companyProfile || null);
setPermissions(userData.permissions || []);
setDocuments({
contracts: docData.contracts || [],
idDocuments: docData.idDocuments || [],
});
} catch (err) {
if (cancelled) return;
log("useAdminUserProfile: error fetching user or permissions", err);
setError("Failed to fetch user or permissions: " + (err.message || err));
setUser(null);
setPersonalProfile(null);
setCompanyProfile(null);
setPermissions([]);
setDocuments({ contracts: [], idDocuments: [] });
}
setLoading(false);
}
if (id && accessToken) fetchAll();
return () => { cancelled = true; };
}, [id, accessToken]);
// Prefer companyProfile for company users, personalProfile for personal users
const profile =
user && user.user_type === "company" ? companyProfile : personalProfile;
// Navigation helpers for enlarge modal
const getEnlargeDocIndex = () => {
if (!enlargeDoc) return { idx: -1, arr: [], type: null };
if (enlargeDoc.user_id_document_id) {
const isImage = (filename) =>
/\.(jpe?g|png|gif|bmp|webp)$/i.test(filename || "");
// Always sort images: front first, then back, then others
const arr = [...documents.idDocuments]
.filter((d) => isImage(d.original_filename))
.sort((a, b) => {
const order = { front: 0, back: 1 };
const aOrder = order[a.side] !== undefined ? order[a.side] : 99;
const bOrder = order[b.side] !== undefined ? order[b.side] : 99;
return aOrder - bOrder;
});
// Use both user_id_document_id and side for uniqueness
const idx = arr.findIndex(
d =>
d.user_id_document_id === enlargeDoc.user_id_document_id &&
d.side === enlargeDoc.side
);
const currentIsImage = isImage(enlargeDoc.original_filename);
return { idx, arr, type: currentIsImage ? "id-image" : "id-single" };
}
if (enlargeDoc.id) {
const arr = documents.contracts;
const idx = arr.findIndex(d => d.id === enlargeDoc.id);
return { idx, arr, type: "contract" };
}
return { idx: -1, arr: [], type: null };
};
const { idx: enlargeIdx, arr: enlargeArr, type: enlargeType } = getEnlargeDocIndex();
const enlargeDocNavigation = {
type: enlargeType,
arr: enlargeArr,
idx: enlargeIdx,
set: setEnlargeDoc,
};
return {
loading,
error,
user,
profile,
permissions,
documents,
enlargeDoc,
setEnlargeDoc,
enlargeDocNavigation,
};
}

View File

@ -0,0 +1,124 @@
import { useState, useEffect, useCallback } from "react";
import useAuthStore from "../../../../store/authStore";
import {
fetchUserList,
fetchUserStats,
fetchUserFull,
deleteAdminUser,
} from "../api/userManagementApi";
import { log } from "../../../../utils/logger";
export function useUserList() {
const accessToken = useAuthStore((s) => s.accessToken);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(() => {
setLoading(true);
setError("");
log("useUserList: loading users");
fetchUserList(accessToken)
.then((data) => {
log("useUserList: fetched users", data);
setUsers(data.users || []);
setError("");
})
.catch((err) => {
log("useUserList: error loading users", err);
setError("Failed to load users: " + err.message);
setUsers([]);
})
.finally(() => setLoading(false));
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
return { users, loading, error, reload: load };
}
export function useUserStats() {
const accessToken = useAuthStore((s) => s.accessToken);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(() => {
setLoading(true);
setError("");
log("useUserStats: loading stats");
fetchUserStats(accessToken)
.then((data) => {
log("useUserStats: fetched stats", data);
setStats(data.stats);
setError("");
})
.catch((err) => {
log("useUserStats: error loading stats", err);
setError("Failed to load statistics: " + err.message);
setStats(null);
})
.finally(() => setLoading(false));
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
return { stats, loading, error, reload: load };
}
export function useUserFull(id) {
const accessToken = useAuthStore((s) => s.accessToken);
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
setLoading(true);
setError("");
log("useUserFull: fetching user full data for id", id);
fetchUserFull(accessToken, id)
.then((data) => {
log("useUserFull: fetched user data", data);
setUserData(data);
setError("");
})
.catch((err) => {
log("useUserFull: error fetching user data", err);
setError("Failed to fetch user data: " + err.message);
setUserData(null);
})
.finally(() => setLoading(false));
}, [accessToken, id]);
return { userData, loading, error };
}
export function useDeleteAdminUser() {
const accessToken = useAuthStore((s) => s.accessToken);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const deleteUser = useCallback(
async (id) => {
setLoading(true);
setError("");
try {
await deleteAdminUser({ id, accessToken });
setLoading(false);
return true;
} catch (err) {
setError(err.message || "Failed to delete user");
setLoading(false);
return false;
}
},
[accessToken]
);
return { deleteUser, loading, error };
}

View File

@ -0,0 +1,158 @@
import React from "react";
import PageLayout from "../../../PageLayout";
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
import useAdminUserProfile from "../hooks/useAdminUserProfile";
import AdminUserProfileCard from "../components/AdminUserProfileCard";
import AdminUserMediaSection from "../components/AdminUserMediaSection";
import AdminUserPermissionsSection from "../components/AdminUserPermissionsSection";
import EnlargeMediaModal from "../components/EnlargeMediaModal";
import AdminUserActionsSection from "../components/AdminUserActionsSection";
import { useTranslation } from "react-i18next";
import PermissionModal from "../components/PermissionModal";
function AdminUserProfilePage() {
const {
loading,
error,
user,
profile,
permissions,
documents,
enlargeDoc,
setEnlargeDoc,
enlargeDocNavigation,
} = useAdminUserProfile();
const { t } = useTranslation("user_management");
// Log backend data for debugging
React.useEffect(() => {
if (user) {
console.log("AdminUserProfilePage: user data", user);
}
if (profile) {
console.log("AdminUserProfilePage: profile data", profile);
}
}, [user, profile]);
const [permissionModalOpen, setPermissionModalOpen] = React.useState(false);
const [permissionsState, setPermissionsState] = React.useState(permissions);
// Refresh permissions after modal update
const refreshPermissions = async () => {
try {
// Replace with your actual API endpoint and authentication as needed
const res = await fetch(`/api/users/${user.id}/permissions`);
const updated = await res.json();
setPermissionsState(updated);
} catch {
// Optionally handle error
}
};
if (loading) {
return (
<PageLayout showHeader showFooter>
<GlobalAnimatedBackground />
<div className="flex justify-center items-center min-h-screen">
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
<span className="text-blue-600 font-bold text-xl">
{t("headings.profileViewLoading")}
</span>
</div>
</div>
</PageLayout>
);
}
if (error || !user) {
return (
<PageLayout showHeader showFooter>
<GlobalAnimatedBackground />
<div className="flex justify-center items-center min-h-screen">
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
<span className="text-red-600 font-bold text-xl">
{error || t("headings.userNotFound")}
</span>
</div>
</div>
</PageLayout>
);
}
// Replace these alert handlers with real implementations if you want requests sent
const handleEditPermissions = () => {
setPermissionModalOpen(true);
};
const handleShowStatistics = () => alert("Show statistics (not implemented)");
const handleExportUserData = () => alert("Export user data (not implemented)");
const handleShowLogs = () => alert("Show logs (not implemented)");
return (
<PageLayout showHeader showFooter>
<GlobalAnimatedBackground />
{enlargeDoc && (
<EnlargeMediaModal
doc={enlargeDoc}
onClose={() => setEnlargeDoc(null)}
navigation={enlargeDocNavigation}
/>
)}
{/* Permission Modal */}
<PermissionModal
open={permissionModalOpen}
userId={user.id}
onClose={() => setPermissionModalOpen(false)}
onSuccess={() => {
refreshPermissions();
setPermissionModalOpen(false);
}}
currentPermissions={permissionsState}
/>
<div className="relative z-10 flex justify-center py-6 sm:py-12 px-1 sm:px-8 w-full">
<div
className="bg-white rounded-2xl shadow-2xl p-2 sm:p-8 w-full"
style={{ maxWidth: "1600px", marginTop: "0.5%" }}
>
{/* Header (removed upper gray back button) */}
<h2 className="text-xl font-bold text-blue-900 mb-6">
{t('headings.main', 'User Management')} / {user.email}
</h2>
{/* Single column order: Profile -> Media -> Permissions -> Actions */}
<div className="space-y-6">
<AdminUserProfileCard
user={user}
profile={profile}
infoTitle={
(user?.userType || user?.user_type) === "company"
? t('headings.companyInfo')
: t('headings.profileInfo')
}
/>
{/* Media moved under profile info */}
<AdminUserMediaSection
documents={documents}
setEnlargeDoc={setEnlargeDoc}
media={documents}
/>
<AdminUserPermissionsSection permissions={permissionsState} />
<AdminUserActionsSection
userId={user.id}
onEditPermissions={handleEditPermissions}
onShowStatistics={handleShowStatistics}
onExportUserData={handleExportUserData}
onShowLogs={handleShowLogs}
// No password reset handler prop passed
/>
</div>
</div>
</div>
</PageLayout>
);
}
export default AdminUserProfilePage;

View File

@ -0,0 +1,62 @@
import React, { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import AdminUserStatistic from "../components/AdminUserStatistic";
import AdminUserListFilterForm from "../components/AdminUserListFilterForm";
import AdminUserList from "../components/AdminUserList";
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
import PageLayout from "../../../PageLayout";
function UserManagement() {
const [filters, setFilters] = useState({});
const [pageSize, setPageSize] = useState(10);
const [refreshKey, setRefreshKey] = useState(0);
const { t } = useTranslation("user_management");
const handlePageSizeChange = useCallback((size) => setPageSize(size), []);
return (
<PageLayout showHeader={true} showFooter={true}>
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
<GlobalAnimatedBackground />
<div className="relative z-10 w-full flex justify-center">
<div
className="rounded-lg shadow-lg p-4 sm:p-8 bg-gray-100 w-full"
style={{
marginTop: "0.5%", // Match dashboard/referral management top margin
marginBottom: "2%",
width: "100%",
maxWidth: "1600px",
}}
>
<h2 className="text-2xl sm:text-5xl font-extrabold text-blue-900 mb-2 text-center tracking-tight">
{t("headings.main")}
</h2>
<p className="text-xs sm:text-base text-blue-900 text-center font-semibold mb-4 sm:mb-8">
{t("headings.subtitle")}
</p>
<div className="bg-white rounded-lg p-2 sm:p-4 shadow-lg w-full">
<AdminUserStatistic />
<AdminUserListFilterForm
filters={filters}
setFilters={setFilters}
pageSize={pageSize}
setPageSize={setPageSize}
onRefresh={() => setRefreshKey((k) => k + 1)}
/>
<AdminUserList
filters={filters}
pageSize={pageSize}
onPageSizeChange={handlePageSizeChange}
refreshKey={refreshKey}
onRefresh={() => setRefreshKey((k) => k + 1)}
/>
</div>
</div>
</div>
</div>
</PageLayout>
);
}
export default UserManagement;

View File

@ -0,0 +1,68 @@
import { authFetch } from "../../../../utils/authFetch";
import { log } from "../../../../utils/logger";
export async function fetchVerificationPendingUsers(accessToken) {
log("fetchVerificationPendingUsers called", { accessToken });
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/admin/verification-pending-users`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
credentials: "include",
}
);
if (!res.ok) {
log("fetchVerificationPendingUsers failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchVerificationPendingUsers success, response:", json);
return json;
}
export async function fetchVerifyUserFull(accessToken, id) {
log("fetchVerifyUserFull called", { accessToken, id });
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/users/${id}/full`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
credentials: "include",
}
);
if (!res.ok) {
log("fetchVerifyUserFull failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchVerifyUserFull success, response:", json);
return json;
}
export async function fetchVerifyUserDocuments(accessToken, id) {
log("fetchVerifyUserDocuments called", { accessToken, id });
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/admin/user/${id}/documents`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
credentials: "include",
}
);
if (!res.ok) {
log("fetchVerifyUserDocuments failed with status:", res.status);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
log("fetchVerifyUserDocuments success, response:", json);
return json;
}

View File

@ -0,0 +1,499 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
// Extract extension from original_filename or object_storage_id
function getFileExtension(doc) {
const filename = doc?.original_filename || doc?.object_storage_id || "";
const match = filename.match(/\.([a-zA-Z0-9]+)$/);
return match ? match[1].toLowerCase() : "";
}
function isImage(doc) {
const ext = getFileExtension(doc);
return ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(ext);
}
function isPdf(doc) {
const ext = getFileExtension(doc);
return ext === "pdf";
}
function VerifyUserDocuments({ docsData }) {
const [enlargeDoc, setEnlargeDoc] = useState(null);
const [enlargeType, setEnlargeType] = useState(null); // "id" or "contract"
const [enlargeIdx, setEnlargeIdx] = useState(null);
const { t } = useTranslation('verify_user');
useEffect(() => {
console.log("[VerifyUserDocuments] docsData:", docsData);
if (docsData && docsData.documents) {
docsData.documents.forEach((doc, idx) => {
console.log(`[VerifyUserDocuments] Document[${idx}]:`, doc);
console.log(`[VerifyUserDocuments] Document[${idx}] signedUrl:`, doc.signedUrl);
console.log(`[VerifyUserDocuments] Document[${idx}] original_filename:`, doc.original_filename);
console.log(`[VerifyUserDocuments] Document[${idx}] type:`, getFileExtension(doc));
});
}
}, [docsData]);
if (!docsData) {
console.log("[VerifyUserDocuments] No docsData provided");
return null;
}
const documents = docsData.documents || [];
// Split ID documents and contracts
const idDocs = documents.filter(
(doc) =>
doc.document_type?.toLowerCase().includes("id") ||
doc.document_type?.toLowerCase().includes("personal_id") ||
doc.document_type?.toLowerCase().includes("company_id")
);
const contracts = documents.filter(
(doc) => doc.document_type?.toLowerCase() === "contract"
);
// Order ID docs: front first, then back
const orderedIdDocs = [...idDocs].sort((a, b) => {
const order = { front: 0, back: 1 };
const aOrder = order[a.side] !== undefined ? order[a.side] : 99;
const bOrder = order[b.side] !== undefined ? order[b.side] : 99;
return aOrder - bOrder;
});
// Modal navigation helpers for ID docs
const handleEnlargeId = (idx) => {
setEnlargeDoc(orderedIdDocs[idx]);
setEnlargeType("id");
setEnlargeIdx(idx);
};
const handleEnlargeContract = (idx) => {
setEnlargeDoc(contracts[idx]);
setEnlargeType("contract");
setEnlargeIdx(idx);
};
const handleClose = () => {
setEnlargeDoc(null);
setEnlargeType(null);
setEnlargeIdx(null);
};
const handlePrev = () => setEnlargeIdx((idx) => {
if (enlargeType === "id" && idx > 0) {
setEnlargeDoc(orderedIdDocs[idx - 1]);
return idx - 1;
}
return idx;
});
const handleNext = () => setEnlargeIdx((idx) => {
if (enlargeType === "id" && idx < orderedIdDocs.length - 1) {
setEnlargeDoc(orderedIdDocs[idx + 1]);
return idx + 1;
}
return idx;
});
return (
<div className="mb-10">
<h2 className="text-xl font-semibold text-gray-800 mb-4 flex items-center gap-2">
<svg className="w-6 h-6 text-blue-400 mr-1" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M3 17l6-6 4 4 5-5" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
{t('headings.media')}
</h2>
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 flex flex-col gap-8 text-lg">
{/* ID Documents */}
<div>
<div className="font-medium text-gray-700 mb-2 flex items-center gap-2">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
{t('headings.idDocuments')}
</div>
{orderedIdDocs.length > 0 ? (
<div className="flex flex-col gap-4 w-full">
{orderedIdDocs.map((doc, idx) => (
<div
key={doc.object_storage_id || doc.original_filename}
className="flex w-full items-center gap-4 bg-white rounded shadow px-4 py-3"
style={{ minWidth: 0 }}
>
{/* Preview */}
{doc.signedUrl && doc.original_filename && isImage(doc) ? (
<img
src={doc.signedUrl}
alt={doc.original_filename}
className="w-16 h-16 object-cover rounded border"
style={{ minWidth: 48, minHeight: 48 }}
onClick={() => handleEnlargeId(idx)}
/>
) : (
<span className="w-16 h-16 flex items-center justify-center bg-gray-100 text-gray-400 rounded border text-xs">
No Preview
</span>
)}
<span className="text-lg font-semibold text-gray-700 capitalize">{doc.side || "ID"}</span>
<span
className="text-gray-500 text-lg truncate cursor-pointer flex-1 min-w-0"
title={doc.original_filename}
>
{doc.original_filename}
</span>
<button
className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
onClick={() => handleEnlargeId(idx)}
>
{t('buttons.enlarge')}
</button>
{/* Only show download for images */}
{/* REMOVE download button for verify user */}
{/* {isImage(doc) && (
<button
className="ml-1 px-2 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
onClick={...}
type="button"
>
Download
</button>
)} */}
</div>
))}
</div>
) : (
<span className="text-gray-400 text-lg">{t('headings.noIdDocs')}</span>
)}
</div>
{/* Contracts */}
<div>
<div className="font-medium text-gray-700 mb-2 flex items-center gap-2">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M7 7v10a2 2 0 002 2h6a2 2 0 002-2V7" stroke="currentColor" strokeWidth="2" fill="none"/>
<rect x="7" y="3" width="10" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
{t('headings.contracts')}
</div>
{contracts.length > 0 ? (
<div className="flex flex-col gap-4 w-full">
{contracts.map((doc, idx) => (
<div
key={doc.object_storage_id || doc.original_filename}
className="flex w-full items-center gap-4 bg-white rounded shadow px-4 py-3"
style={{ minWidth: 0 }}
>
{/* PDF Preview */}
{doc.signedUrl && doc.original_filename && isPdf(doc) ? (
<>
{console.log("[VerifyUserDocuments] Rendering PDF preview for:", doc.original_filename, doc.signedUrl)}
<embed
src={doc.signedUrl}
type="application/pdf"
className="w-16 h-16 rounded border bg-gray-50"
style={{ minWidth: 48, minHeight: 48 }}
onClick={() => handleEnlargeContract(idx)}
/>
</>
) : (
<span className="w-16 h-16 flex items-center justify-center bg-gray-100 text-gray-400 rounded border text-xs">
No Preview
</span>
)}
<span
className="text-gray-500 text-lg truncate cursor-pointer flex-1 min-w-0"
title={doc.original_filename}
>
{doc.original_filename}
</span>
<button
className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
onClick={() => handleEnlargeContract(idx)}
>
{t('buttons.enlarge')}
</button>
{/* No download button for contracts */}
</div>
))}
</div>
) : (
<span className="text-gray-400 text-lg">{t('headings.noContracts')}</span>
)}
</div>
</div>
{/* Enlarged Modal for ID Documents and Contracts */}
{enlargeDoc && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{
background: "rgba(255,255,255,0.2)",
backdropFilter: "blur(8px)",
}}
>
<div
className="relative bg-white rounded-xl shadow-2xl flex flex-col items-center"
style={{
width: "90vw",
maxWidth: "1200px",
minWidth: "340px",
padding: "2.5rem",
boxSizing: "border-box",
}}
>
{/* Close button */}
<button
className="absolute top-4 right-4 flex items-center justify-center w-9 h-9 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-600 hover:text-red-500 text-xl font-bold shadow transition"
onClick={handleClose}
aria-label="Close"
tabIndex={0}
style={{ zIndex: 10 }}
>
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="none">
<path d="M6 6l8 8M6 14L14 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
</button>
{/* Navigation arrows for ID images only */}
{enlargeType === "id" && orderedIdDocs.length > 1 && (
<div className="absolute left-0 right-0 top-1/2 flex justify-between px-10 -translate-y-1/2">
{/* Left arrow: only if idx > 0, else render empty space */}
<div style={{ width: 40, height: 40 }}>
{enlargeIdx > 0 ? (
<button
className="bg-gray-200 hover:bg-gray-300 rounded-full w-10 h-10 flex items-center justify-center text-2xl text-gray-600 shadow"
onClick={(e) => { e.stopPropagation(); handlePrev(); }}
aria-label="Previous"
tabIndex={0}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</button>
) : null}
</div>
<div style={{ width: 40, height: 40 }}>
{enlargeIdx < orderedIdDocs.length - 1 ? (
<button
className="bg-gray-200 hover:bg-gray-300 rounded-full w-10 h-10 flex items-center justify-center text-2xl text-gray-600 shadow"
onClick={(e) => { e.stopPropagation(); handleNext(); }}
aria-label="Next"
tabIndex={0}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</button>
) : null}
</div>
</div>
)}
{/* Header for ID document */}
{enlargeType === "id" && (
<div className="mb-1 text-2xl font-bold text-blue-700 text-center w-full">
{enlargeDoc.side
? `${enlargeDoc.side.charAt(0).toUpperCase() + enlargeDoc.side.slice(1)} ${t('headings.ofId')}`
: "ID Document"}
{enlargeDoc.id_type ? ` (${enlargeDoc.id_type})` : ""}
</div>
)}
{enlargeType === "contract" && (
<div className="mb-1 text-2xl font-bold text-blue-700 text-center w-full">
{t('headings.contracts')}
</div>
)}
<div className="mb-2 text-gray-700 font-semibold text-center text-xl">
{enlargeDoc.original_filename}
</div>
<div
className="flex items-center justify-center w-full"
style={{
width: "100%",
maxWidth: "100%",
minHeight: 120,
minWidth: 0,
marginBottom: 24,
position: "relative",
}}
>
{/* Image preview with zoom and navigation */}
{enlargeType === "id" && isImage(enlargeDoc) ? (
<>
{/* Navigation arrows for ID images only, always at far left/right, outside content */}
{orderedIdDocs.length > 1 && (
<>
{enlargeIdx > 0 && (
<button
className="absolute left-[-60px] top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-full w-10 h-10 flex items-center justify-center text-2xl text-gray-600 shadow pointer-events-auto"
onClick={(e) => { e.stopPropagation(); handlePrev(); }}
aria-label="Previous"
tabIndex={0}
style={{ pointerEvents: "auto" }}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</button>
)}
{enlargeIdx < orderedIdDocs.length - 1 && (
<button
className="absolute right-[-60px] top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-full w-10 h-10 flex items-center justify-center text-2xl text-gray-600 shadow pointer-events-auto"
onClick={(e) => { e.stopPropagation(); handleNext(); }}
aria-label="Next"
tabIndex={0}
style={{ pointerEvents: "auto" }}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</button>
)}
</>
)}
<ZoomableImage src={enlargeDoc.signedUrl} alt={enlargeDoc.original_filename} />
</>
) : enlargeType === "contract" && isPdf(enlargeDoc) ? (
// FIX: Use <embed> for PDF preview, not <iframe>
<embed
src={enlargeDoc.signedUrl}
type="application/pdf"
className="rounded border bg-gray-50"
style={{
width: "90vw",
height: "75vh",
minWidth: 0,
minHeight: 0,
maxWidth: "100%",
maxHeight: "100%",
border: "none",
background: "#f9fafb"
}}
/>
) : (
<span className="text-gray-400 text-xl">{t('headings.noPreview')}</span>
)}
</div>
</div>
</div>
)}
</div>
);
}
// ZoomableImage component for modal
function ZoomableImage({ src, alt }) {
const [zoom, setZoom] = useState(1);
const [dragging, setDragging] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [start, setStart] = useState({ x: 0, y: 0 });
const imgRef = React.useRef();
// Mouse wheel zoom
const handleWheel = (e) => {
e.preventDefault();
setZoom((z) => Math.max(1, Math.min(4, z + (e.deltaY < 0 ? 0.2 : -0.2))));
};
// Mouse drag for panning
const handleMouseDown = (e) => {
setDragging(true);
setStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
};
const handleMouseUp = () => setDragging(false);
const handleMouseMove = (e) => {
if (dragging) {
setOffset({
x: e.clientX - start.x,
y: e.clientY - start.y,
});
}
};
// Touch events for mobile
const handleTouchStart = (e) => {
if (e.touches.length === 1) {
setDragging(true);
setStart({
x: e.touches[0].clientX - offset.x,
y: e.touches[0].clientY - offset.y,
});
}
};
const handleTouchEnd = () => setDragging(false);
const handleTouchMove = (e) => {
if (dragging && e.touches.length === 1) {
setOffset({
x: e.touches[0].clientX - start.x,
y: e.touches[0].clientY - start.y,
});
}
};
// Reset zoom/pan on image change
React.useEffect(() => {
setZoom(1);
setOffset({ x: 0, y: 0 });
}, [src]);
return (
<div
className="relative flex items-center justify-center"
style={{
width: "100%",
height: "70vh",
overflow: "hidden",
background: "#f9fafb",
borderRadius: "0.75rem",
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
>
<img
ref={imgRef}
src={src}
alt={alt}
draggable={false}
style={{
transform: `scale(${zoom}) translate(${offset.x / zoom}px, ${offset.y / zoom}px)`,
transition: dragging ? "none" : "transform 0.2s",
maxWidth: "100%",
maxHeight: "100%",
cursor: zoom > 1 ? "grab" : "zoom-in",
userSelect: "none",
}}
/>
{/* Lupe/zoom controls */}
<div className="absolute bottom-4 right-4 flex gap-2 bg-white/80 rounded-lg shadow p-2">
<button
className="px-2 py-1 rounded bg-blue-100 text-blue-700 font-bold text-lg hover:bg-blue-200"
onClick={() => setZoom((z) => Math.max(1, z - 0.2))}
disabled={zoom <= 1}
type="button"
>
-
</button>
<span className="px-2 font-semibold text-blue-900">{Math.round(zoom * 100)}%</span>
<button
className="px-2 py-1 rounded bg-blue-100 text-blue-700 font-bold text-lg hover:bg-blue-200"
onClick={() => setZoom((z) => Math.min(4, z + 0.2))}
disabled={zoom >= 4}
type="button"
>
+
</button>
<button
className="px-2 py-1 rounded bg-gray-100 text-gray-700 font-bold text-lg hover:bg-gray-200"
onClick={() => { setZoom(1); setOffset({ x: 0, y: 0 }); }}
type="button"
>
Reset
</button>
</div>
</div>
);
}
export default VerifyUserDocuments;

View File

@ -0,0 +1,613 @@
import React from "react";
import { useTranslation } from "react-i18next";
// Helper to format label
function formatLabel(label) {
return label
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (l) => l.toUpperCase());
}
// Helper to format value (dates, booleans, etc.)
function formatValue(value) {
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}T/)) {
return new Date(value).toLocaleString();
}
if (typeof value === "boolean") {
return value ? "Yes" : "No";
}
if (value === null || value === undefined) {
return "-";
}
return String(value);
}
// Icon mapping helpers
const userInfoIcons = {
"Contact Person Name": (
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Contact Person Phone": (
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Created At": (
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Updated At": (
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Last Login": (
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 8v4l3 3" stroke="currentColor" strokeWidth="2" fill="none"/>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"First Name": (
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 4-7 8-7s8 3 8 7"/>
</svg>
),
"Lastname": (
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<circle cx="9" cy="8" r="4"/>
<path d="M2 20c0-3.5 3.5-6 7-6"/>
<circle cx="18" cy="10" r="3"/>
<path d="M18 13c2.5 0 4 1.5 4 3"/>
</svg>
),
"Full Name": (
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<path d="M12 12c2.5 0 4.5-2 4.5-4.5S14.5 3 12 3 7.5 5 7.5 7.5 9.5 12 12 12z"/>
<path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
</svg>
),
"Phone": (
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<path d="M5 4h4l2 5-3 2c1.2 2.4 3.1 4.3 5.5 5.5l2-3 5 2v4c0 .6-.4 1-1 1A16 16 0 014 5c0-.6.4-1 1-1z"/>
</svg>
),
"Email": (
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<path d="M3 7l9 6 9-6"/>
<rect x="3" y="5" width="18" height="14" rx="2"/>
</svg>
),
"Date Of Birth": (
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<rect x="4" y="5" width="16" height="16" rx="2"/>
<path d="M8 3v4M16 3v4M4 11h16"/>
</svg>
),
"Referrer Email": (
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<path d="M4 4h9v6H4z"/>
<path d="M13 7l7 3-7 3v-2H8V9h5V7z"/>
<path d="M4 14h9v6H4z"/>
</svg>
),
"Last Login At": (
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<circle cx="12" cy="12" r="10"/>
<path d="M12 7v5l3 3"/>
</svg>
)
};
const companyInfoIcons = {
"Company Name": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company Email": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8" stroke="currentColor" strokeWidth="2" fill="none"/>
<rect x="3" y="8" width="18" height="8" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company Phone": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company Address": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M3 10v10a1 1 0 001 1h16a1 1 0 001-1V10" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M7 10V7a5 5 0 0110 0v3" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company Postal Code": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company City": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company Country": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company Business Type": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company Number of Employees": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
{/* # symbol not supported in SVG, so just circle */}
</svg>
),
"Company Registration Number": (
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
),
"Nationality": (
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15 15 0 010 20M12 2a15 15 0 000 20"/>
</svg>
),
"Address": (
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
<path d="M12 21s7-6.2 7-11.5S15.9 3 12 3 5 5.6 5 9.5 12 21 12 21z"/>
<circle cx="12" cy="9.5" r="2.5"/>
</svg>
),
"Zip Code": (
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="4" width="16" height="16" rx="2"/>
<path d="M8 8h8v8H8z"/>
</svg>
),
"City": (
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 21V10l6-3 6 3 6-3v11"/>
<path d="M9 21V12M15 21V12"/>
</svg>
),
"Country": (
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 3h6l2 3h6v13H5z"/>
<path d="M5 9h14"/>
</svg>
),
"Phone Secondary": (
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 4h4l2 5-3 2c1.2 2.4 3.1 4.3 5.5 5.5l2-3 5 2v4"/>
</svg>
),
"Emergency Contact Name": (
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="8" r="4"/>
<path d="M2 21c0-3.5 3.5-6 7-6"/>
<path d="M17 7h4l-2 4h3l-5 6v-4h-2z"/>
</svg>
),
"Emergency Contact Phone": (
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 4h4l2 5-3 2c1.2 2.4 3.1 4.3 5.5 5.5l2-3 5 2v4"/>
<path d="M18 2v4M18 10v.01"/>
</svg>
),
// fallback for other fields
};
const bankDetailsIcons = {
"Account Holder Name": (
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"Company Account Holder Name": (
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
"IBAN": (
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
),
"Company IBAN": (
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
),
};
// InfoCard with icons for each field
function InfoCard({ title, icon, data, color = "blue", badges = [] }) {
if (!data) return null;
const { t } = useTranslation('verify_user'); // added
const iconMap = color === "blue" ? userInfoIcons : color === "purple" ? companyInfoIcons : {};
// translation map
const labelTranslations = {
"First Name": t('fields.firstName'),
"Lastname": t('fields.lastName'),
"Full Name": t('fields.fullName'),
"Phone": t('fields.phone'),
"Email": t('fields.email'),
"Date Of Birth": t('fields.dateOfBirth'),
"Referrer Email": t('fields.referrerEmail'),
"Created At": t('fields.createdAt'),
"Updated At": t('fields.updatedAt'),
"Last Login At": t('fields.lastLoginAt'),
"Contact Person Name": t('fields.contactPersonName', { defaultValue: "Contact Person Name" }),
"Contact Person Phone": t('fields.contactPersonPhone', { defaultValue: "Contact Person Phone" }),
"Last Login": t('fields.lastLogin', { defaultValue: "Last Login" }),
"Nationality": t('fields.nationality'),
"Address": t('fields.address'),
"Zip Code": t('fields.zipCode'),
"City": t('fields.city'),
"Country": t('fields.country'),
"Phone Secondary": t('fields.phoneSecondary'),
"Emergency Contact Name": t('fields.emergencyContactName'),
"Emergency Contact Phone": t('fields.emergencyContactPhone'),
"Company Name": t('fields.companyName', { defaultValue: "Company Name" }),
"Company Email": t('fields.companyEmail', { defaultValue: "Company Email" }),
"Company Phone": t('fields.companyPhone', { defaultValue: "Company Phone" }),
"Company Address": t('fields.companyAddress', { defaultValue: "Company Address" }),
"Company Postal Code": t('fields.companyPostalCode', { defaultValue: "Company Postal Code" }),
"Company City": t('fields.companyCity', { defaultValue: "Company City" }),
"Company Country": t('fields.companyCountry', { defaultValue: "Company Country" }),
"Company Business Type": t('fields.companyBusinessType', { defaultValue: "Company Business Type" }),
"Company Number of Employees": t('fields.companyNumberOfEmployees', { defaultValue: "Company Number of Employees" }),
"Company Registration Number": t('fields.companyRegistrationNumber', { defaultValue: "Company Registration Number" })
};
return (
<div className={`bg-${color}-50 border border-${color}-100 rounded-xl shadow p-6 flex flex-col gap-2`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h2 className={`text-xl font-semibold text-${color}-900`}>{title}</h2>
{badges.length > 0 && (
<div className="flex gap-2 ml-2">
{badges.map((badge, idx) => (
<span
key={idx}
className={
badge.label === "Company"
? "px-2 py-1 rounded-full text-base font-semibold bg-purple-100 text-purple-800 border border-purple-200"
: badge.label === "Personal"
? "px-2 py-1 rounded-full text-base font-semibold bg-blue-100 text-blue-800 border border-blue-200"
: badge.className // fallback for other badges
}
>
{badge.label}
</span>
))}
</div>
)}
</div>
<ul className="text-gray-700 text-lg">
{Object.entries(data).map(([key, value]) => {
const label = labelTranslations[key] || formatLabel(key);
return (
<li key={key} className="mb-2 flex items-center gap-2">
{iconMap[key] || <span className="w-4 h-4"></span>}
<span className="font-medium">{label}:</span> {formatValue(value)}
</li>
);
})}
</ul>
</div>
);
}
// BankDetailsCard with icons
function BankDetailsCard({ accountHolderName, iban, isCompany }) {
const { t } = useTranslation('verify_user');
const holderLabel = isCompany ? t('fields.companyAccountHolderName') : t('fields.accountHolderName');
const ibanLabel = isCompany ? t('fields.companyIban', { defaultValue: t('fields.iban') }) : t('fields.iban');
return (
<div className="bg-green-50 border border-green-100 rounded-xl shadow p-6 flex flex-col gap-2 mt-6">
<div className="flex items-center gap-2 mb-3">
{/* Bank icon */}
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="3" y="10" width="18" height="11" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M3 10l9-7 9 7" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<h2 className="text-xl font-semibold text-green-900">{t('headings.bankDetails')}</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-gray-700 text-lg">
<div className="flex items-center gap-2">
{bankDetailsIcons[isCompany ? "Company Account Holder Name" : "Account Holder Name"]}
<span className="font-medium">
{holderLabel}:
</span> {accountHolderName || "-"}
</div>
<div className="flex items-center gap-2">
{bankDetailsIcons[isCompany ? "Company IBAN" : "IBAN"]}
<span className="font-medium">
{ibanLabel}:
</span> {iban || "-"}
</div>
</div>
</div>
);
}
function UserStatusStepper({ userStatus }) {
const { t } = useTranslation('verify_user');
if (!userStatus || typeof userStatus !== "object") return null;
const steps = [
{
key: "email_verified",
label: t('steps.emailVerified'),
fulfilled: !!userStatus.email_verified,
date: userStatus.email_verified_at,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M16 12a4 4 0 01-8 0V8a4 4 0 018 0v4z" stroke="currentColor" strokeWidth="2" fill="none"/>
<circle cx="12" cy="4" r="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
},
{
key: "profile_completed",
label: t('steps.profileCompleted'),
fulfilled: !!userStatus.profile_completed,
date: userStatus.profile_completed_at,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M12 16v-4m0-4h.01" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
},
{
key: "documents_uploaded",
label: t('steps.documentsUploaded'),
fulfilled: !!userStatus.documents_uploaded,
date: userStatus.documents_uploaded_at,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
</svg>
),
},
{
key: "contract_signed",
label: t('steps.contractSigned'),
fulfilled: !!userStatus.contract_signed,
date: userStatus.contract_signed_at,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="2" fill="none"/>
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
),
},
];
// Optionally, filter out steps that don't exist in userStatus
const filteredSteps = steps.filter(
step => step.key in userStatus || step.date
);
return (
<div className="bg-green-50 border border-green-100 rounded-xl shadow p-6 flex flex-col gap-2">
{/* User Status header and status badge in one row */}
<div className="flex items-center gap-2 mb-3">
{/* Information Message icon */}
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M12 16v-4" stroke="currentColor" strokeWidth="2" fill="none"/>
<circle cx="12" cy="8" r="1" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
<h2 className="text-xl font-semibold text-green-900">{t('headings.userStatus')}</h2>
{userStatus.status && (
<span className={`ml-2 px-4 py-2 rounded-full font-semibold text-base border ${
userStatus.status === "active"
? "bg-green-100 text-green-800 border-green-200"
: userStatus.status === "pending"
? "bg-yellow-100 text-yellow-800 border-yellow-200"
: "bg-gray-100 text-gray-800 border-gray-200"
}`}>
{formatLabel(userStatus.status)}
</span>
)}
</div>
{/* Four cards in one row, responsive */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-2">
{filteredSteps.map((step, idx) => (
<div
key={step.key}
className={`flex flex-col items-center px-4 py-2 rounded-xl border shadow-sm min-w-[140px] ${
step.fulfilled
? "bg-green-100 border-green-200"
: "bg-gray-100 border-gray-200"
}`}
>
<div className="flex items-center gap-2 mb-1">
{step.icon}
<span className={`font-semibold text-lg ${step.fulfilled ? "text-green-800" : "text-gray-500"}`}>
{step.label}
</span>
{step.fulfilled ? (
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
) : (
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
)}
</div>
{step.date && (
<span className="text-xs text-gray-500">
{new Date(step.date).toLocaleString()}
</span>
)}
</div>
))}
</div>
</div>
);
}
function VerifyUserInformation({ userData }) {
const { t } = useTranslation('verify_user');
if (!userData) return null;
const { user, profile, userStatus } = userData;
// Log backend data for debugging
React.useEffect(() => {
console.log("VerifyUserInformation: user data", user);
console.log("VerifyUserInformation: profile data", profile);
console.log("VerifyUserInformation: userStatus data", userStatus);
}, [user, profile, userStatus]);
// Detect company user from both userType and user_type
const isCompany =
user?.userType === "company" ||
user?.user_type === "company";
// User Info fields for company
const companyUserInfo = {
"Contact Person Name": profile?.contact_person_name,
"Contact Person Phone": profile?.contact_person_phone,
"Created At": user?.createdAt ? new Date(user.createdAt).toLocaleString() : "-",
"Updated At": user?.updatedAt ? new Date(user.updatedAt).toLocaleString() : "-",
"Last Login": user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "-",
};
// Company Info fields for company
const companyProfileInfo = {
"Company Name": profile?.company_name,
"Company Email": user?.email,
"Company Phone": profile?.phone,
"Company Address": profile?.address,
"Company Postal Code": profile?.zip_code,
"Company City": profile?.city,
"Company Country": profile?.country,
"Company Business Type": profile?.branch,
"Company Number of Employees": profile?.number_of_employees,
"Company Registration Number": profile?.registration_number,
};
// --- NEW: Ordered personal user info (only for personal users) ---
const personalUserInfo = {
"First Name": profile?.first_name ?? user?.firstName,
"Lastname": profile?.last_name ?? user?.lastName,
"Full Name": user?.fullName ?? `${profile?.first_name || ""} ${profile?.last_name || ""}`.trim(),
"Phone": user?.phone ?? profile?.phone,
"Email": user?.email,
"Date Of Birth": user?.dateOfBirth || profile?.date_of_birth,
"Referrer Email": user?.referralEmail,
"Created At": user?.createdAt,
"Updated At": user?.updatedAt,
"Last Login At": user?.lastLoginAt
};
const personalProfileInfo = {
"Nationality": profile?.nationality,
"Address": profile?.address,
"Zip Code": profile?.zip_code,
"City": profile?.city,
"Country": profile?.country,
"Phone Secondary": profile?.phone_secondary,
"Emergency Contact Name": profile?.emergency_contact_name,
"Emergency Contact Phone": profile?.emergency_contact_phone
};
// ---------------------------------------------------------------
// User type badge
const userTypeBadge = isCompany
? { label: "Company", className: "bg-purple-100 text-purple-800 border border-purple-200" }
: { label: "Personal", className: "bg-blue-100 text-blue-800 border border-blue-200" };
// Role badge
const roleBadge = user?.role
? { label: user.role, className: "bg-yellow-100 text-yellow-800 border border-yellow-200" }
: null;
// Status badge
const statusBadge = userStatus?.status
? {
label: userStatus.status,
className:
userStatus.status === "active"
? "bg-green-100 text-green-800 border border-green-200"
: userStatus.status === "pending"
? "bg-yellow-100 text-yellow-800 border border-yellow-200"
: "bg-gray-100 text-gray-800 border border-gray-200"
}
: null;
// Card titles based on user type
const profileTitle = isCompany ? t('headings.companyInfo') : t('headings.profileInfo');
// Bank details for company and personal users
const bankDetails = isCompany
? {
accountHolderName: profile?.account_holder_name,
iban: user?.iban || profile?.iban,
}
: {
accountHolderName: profile?.account_holder_name || user?.accountHolderName,
iban: user?.iban || profile?.iban,
};
return (
<div className="mb-8 w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
<InfoCard
title={t('headings.userInfo')}
icon={
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none" />
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
}
data={isCompany ? companyUserInfo : personalUserInfo}
color="blue"
badges={[userTypeBadge, roleBadge].filter(Boolean)}
/>
<InfoCard
title={profileTitle}
icon={
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z" stroke="currentColor" strokeWidth="2" fill="none" />
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
}
data={isCompany ? companyProfileInfo : personalProfileInfo}
color="purple"
/>
</div>
{/* Bank Details Card below User/Profile Info */}
<BankDetailsCard
accountHolderName={bankDetails.accountHolderName}
iban={bankDetails.iban}
isCompany={isCompany}
/>
{/* Modern User Status Stepper below */}
<div className="mt-6">
<UserStatusStepper userStatus={userStatus} />
</div>
</div>
);
}
export default VerifyUserInformation;

View File

@ -0,0 +1,317 @@
import React, { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useVerificationPendingUsers } from "../hooks/useVerifyUser";
import { useTranslation } from "react-i18next";
function VerifyUserList({ pageSize = 10, filters = {} }) {
const { users: allUsers, loading, error } = useVerificationPendingUsers();
const [page, setPage] = useState(1);
const navigate = useNavigate();
const { t } = useTranslation('verify_user');
// Reset to page 1 when filters or pageSize change
useEffect(() => {
setPage(1);
}, [filters, pageSize]);
// Client-side filtering
const filteredUsers = useMemo(() => {
let users = allUsers;
if (filters.search) {
const search = filters.search.toLowerCase();
users = users.filter(
(u) =>
(u.email && u.email.toLowerCase().includes(search)) ||
(u.first_name && u.first_name.toLowerCase().includes(search)) ||
(u.last_name && u.last_name.toLowerCase().includes(search)) ||
(u.company_name && u.company_name.toLowerCase().includes(search))
);
}
if (filters.user_type) {
users = users.filter((u) => u.user_type === filters.user_type);
}
if (filters.status) {
users = users.filter((u) => u.status === filters.status);
}
if (filters.role) {
users = users.filter((u) => u.role === filters.role);
}
return users;
}, [allUsers, filters]);
// Pagination
const totalUsers = filteredUsers.length;
const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize));
const pagedUsers = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredUsers.slice(start, start + pageSize);
}, [filteredUsers, page, pageSize]);
return (
<div className="bg-white overflow-hidden shadow-lg rounded-xl border border-gray-100">
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
<h2 className="text-xl font-semibold text-gray-900">{t('headings.queueTitle')}</h2>
<p className="text-gray-600 mt-1">
{loading
? t('headings.loadingUser')
: t('table.showing', { shown: pagedUsers.length, total: totalUsers })}
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.user')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.type')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.status')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.role')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.created')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.lastLogin')}
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('table.actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-400">
Loading...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-red-500">
{error}
</td>
</tr>
) : pagedUsers.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center">
<div className="text-gray-400">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
></path>
</svg>
<p className="text-gray-500 text-lg font-medium">
{t('table.noUsers')}
</p>
<p className="text-sm text-gray-400 mt-1">
{t('table.adjustFilters')}
</p>
</div>
</td>
</tr>
) : (
pagedUsers.map((user) => (
<tr
key={user.id}
className="hover:bg-gray-50 transition-colors duration-150"
>
<td className="px-6 py-4">
<div className="flex items-center">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center mr-4 shadow-lg">
<span className="text-sm font-bold text-white">
{user.user_type === "personal"
? `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`
: user.company_name?.substring(0, 2)}
</span>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">
{user.user_type === "personal"
? `${user.first_name ?? ""} ${user.last_name ?? ""}`
: user.company_name}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
user.user_type === "company"
? "bg-purple-100 text-purple-800 border border-purple-200"
: "bg-blue-100 text-blue-800 border border-blue-200"
}`}
>
<div
className={`w-2 h-2 rounded-full mr-2 ${
user.user_type === "company"
? "bg-purple-400"
: "bg-blue-400"
}`}
></div>
{user.user_type === "company" ? "Company" : "Personal"}
</span>
</td>
<td className="px-6 py-4">
{user.status === "active" && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
<div className="w-2 h-2 bg-green-400 rounded-full mr-2"></div>
{t('status.active')}
</span>
)}
{user.status === "pending" && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
<div className="w-2 h-2 bg-yellow-400 rounded-full mr-2"></div>
{t('status.pending')}
</span>
)}
{user.status === "suspended" && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-200">
<div className="w-2 h-2 bg-red-400 rounded-full mr-2"></div>
{t('status.suspended')}
</span>
)}
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
user.role === "super_admin"
? "bg-red-100 text-red-800 border border-red-200"
: user.role === "admin"
? "bg-indigo-100 text-indigo-800 border border-indigo-200"
: "bg-gray-100 text-gray-800 border border-gray-200"
}`}
>
<div
className={`w-2 h-2 rounded-full mr-2 ${
user.role === "super_admin"
? "bg-red-400"
: user.role === "admin"
? "bg-indigo-400"
: "bg-gray-400"
}`}
></div>
{t(`roles.${user.role}`)}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-900 font-medium">
{user.created_at?.slice(0, 10) ?? "-"}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
{user.last_login_at ? (
<span className="font-medium">
{user.last_login_at.slice(0, 10)}
</span>
) : (
<span className="text-gray-400 italic">{t('table.never')}</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex space-x-2">
<button
className="inline-flex items-center px-3 py-1.5 border border-green-300 text-xs font-medium rounded-md text-green-700 bg-green-50 hover:bg-green-100 hover:border-green-400 transition-colors duration-200"
onClick={() => navigate(`/admin/user-management/verify/${user.id}`)}
>
<svg
className="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
></path>
</svg>
{t('buttons.verify')}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-700">
<span className="font-medium">
{t('table.pageOf', { page, pages: totalPages })}
</span>
<span className="text-gray-500"> ({t('table.totalUsers', { total: totalUsers })})</span>
</div>
<div className="flex space-x-2">
<button
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md ${
page === 1
? "text-gray-400 bg-white cursor-not-allowed"
: "text-gray-700 bg-white hover:bg-gray-100"
}`}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 19l-7-7 7-7"
></path>
</svg>
{t('buttons.previous')}
</button>
<button
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md ${
page === totalPages
? "text-gray-400 bg-white cursor-not-allowed"
: "text-gray-700 bg-white hover:bg-gray-100"
}`}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
{t('buttons.next')}
<svg
className="w-4 h-4 ml-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</button>
</div>
</div>
</div>
</div>
);
}
export default VerifyUserList;

View File

@ -0,0 +1,102 @@
import React from "react";
import { useTranslation } from "react-i18next";
function VerifyUserListFilterForm({ filters = {}, setFilters, pageSize, setPageSize }) {
const { t } = useTranslation('verify_user');
const handleChange = (e) => {
setFilters({ ...filters, [e.target.name]: e.target.value });
};
const handleSubmit = (e) => {
e.preventDefault();
setFilters({ ...filters });
};
return (
<div className="bg-white overflow-hidden shadow-lg rounded-xl border border-gray-100 mb-8">
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
<h2 className="text-xl font-semibold text-gray-900">{t('headings.filterTitle')}</h2>
<p className="text-gray-600 mt-1">{t('headings.filterSubtitle')}</p>
</div>
<div className="p-6">
<form
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"
onSubmit={handleSubmit}
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<input
name="search"
value={filters.search || ""}
onChange={handleChange}
type="text"
placeholder={t('filters.searchPlaceholder')}
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.userType')}</label>
<select
name="user_type"
value={filters.user_type || ""}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
>
<option value="">{t('filters.allTypes')}</option>
<option value="personal">{t('filters.personal')}</option>
<option value="company">{t('filters.company')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.role')}</label>
<select
name="role"
value={filters.role || ""}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
>
<option value="">{t('filters.allRoles')}</option>
<option value="user">{t('roles.user')}</option>
<option value="admin">{t('roles.admin')}</option>
<option value="super_admin">{t('roles.super_admin')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.usersPerPage')}</label>
<select
value={pageSize}
onChange={e => setPageSize(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div className="flex items-end">
<button
type="submit"
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white px-4 py-2 rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
<svg className="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
{t('buttons.filter')}
</button>
</div>
</form>
</div>
</div>
);
}
export default VerifyUserListFilterForm;

View File

@ -0,0 +1,146 @@
import React, { useEffect, useState } from "react";
import { authFetch } from "../../../../utils/authFetch";
import { showToast } from "../../../toast/toastUtils.js";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
function VerifyUserSetPermission({ userData }) {
const [allPermissions, setAllPermissions] = useState([]);
const [selected, setSelected] = useState(
Array.isArray(userData?.permissions)
? userData.permissions.map((p) => (typeof p === "string" ? p : p.name))
: []
);
const [submitting, setSubmitting] = useState(false);
const [successMsg, setSuccessMsg] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const navigate = useNavigate();
const { t } = useTranslation('verify_user');
// Fetch all available permissions
useEffect(() => {
async function fetchPermissions() {
try {
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/permissions`,
{ method: "GET" }
);
console.log("[VerifyUserSetPermission] Permissions fetch response:", res);
if (!res.ok) throw new Error("Failed to fetch permissions");
const data = await res.json();
console.log("[VerifyUserSetPermission] Permissions response data:", data);
// If permissions are objects, map to names
setAllPermissions(
Array.isArray(data.permissions)
? data.permissions.map((perm) =>
typeof perm === "string" ? perm : perm.name
)
: []
);
} catch (e) {
setErrorMsg(t('messages.loadPermFail'));
}
}
fetchPermissions();
}, []);
// Log selected permissions whenever they change
useEffect(() => {
console.log("[VerifyUserSetPermission] Selected permissions:", selected);
}, [selected]);
// Handle checkbox change
const handleCheckbox = (perm) => {
setSelected((prev) =>
prev.includes(perm)
? prev.filter((p) => p !== perm)
: [...prev, perm]
);
};
// Handle verify & set permissions
const handleVerify = async (e) => {
e.preventDefault();
setSubmitting(true);
setSuccessMsg("");
setErrorMsg("");
try {
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/admin/verify-user/${
userData.user?.id || userData.user?.user_id || userData.user_id
}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ permissions: selected }),
}
);
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || "Verification failed");
}
setSuccessMsg(t('messages.verifySuccess'));
showToast({ type: "success", message: t('messages.verifySuccess') });
// Redirect after short delay
setTimeout(() => {
navigate("/admin/verify-users");
}, 1200);
} catch (err) {
setErrorMsg(err.message || t('messages.verifyFail'));
showToast({ type: "error", message: err.message || t('messages.verifyFail') });
}
setSubmitting(false);
};
return (
<form
className="bg-white rounded-2xl shadow-xl border border-blue-100 max-w-2xl mx-auto p-8 mt-12"
onSubmit={handleVerify}
>
<h2 className="text-2xl font-bold text-blue-900 mb-6 text-center">
{t('headings.setPermissionsTitle')}
</h2>
<div className="mb-6">
<p className="text-gray-700 font-medium mb-4 text-center">
{t('buttons.verify')}
</p>
<div className="flex flex-wrap gap-4 justify-center">
{allPermissions.length === 0 ? (
<span className="text-gray-400">{t('messages.noPermissions')}</span>
) : (
allPermissions.map((perm) => (
<label
key={perm}
className="flex items-center bg-blue-50 px-4 py-2 rounded-lg shadow border border-blue-200 cursor-pointer hover:border-blue-400 transition"
>
<input
type="checkbox"
className="mr-2 accent-blue-600"
checked={selected.includes(perm)}
onChange={() => handleCheckbox(perm)}
disabled={submitting}
/>
<span className="text-sm text-blue-900 font-semibold">{perm}</span>
</label>
))
)}
</div>
</div>
<button
type="submit"
className="w-full mt-4 px-6 py-3 bg-blue-700 text-white font-bold rounded-lg shadow hover:bg-blue-800 transition disabled:opacity-60"
disabled={submitting}
>
{submitting ? t('messages.verifying') : t('buttons.verifySet')}
</button>
{successMsg && (
<div className="mt-4 text-green-600 font-semibold text-center">{successMsg}</div>
)}
{errorMsg && (
<div className="mt-4 text-red-600 font-semibold text-center">{errorMsg}</div>
)}
</form>
);
}
export default VerifyUserSetPermission;

View File

@ -0,0 +1,95 @@
import { useState, useEffect, useCallback } from "react";
import useAuthStore from "../../../../store/authStore";
import {
fetchVerificationPendingUsers,
fetchVerifyUserFull,
fetchVerifyUserDocuments,
} from "../api/verifyUserApi";
import { log } from "../../../../utils/logger";
export function useVerificationPendingUsers() {
const accessToken = useAuthStore((s) => s.accessToken);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(() => {
setLoading(true);
setError("");
log("useVerificationPendingUsers: loading users");
fetchVerificationPendingUsers(accessToken)
.then((data) => {
log("useVerificationPendingUsers: fetched users", data);
setUsers(data.users || []);
setError("");
})
.catch((err) => {
log("useVerificationPendingUsers: error loading users", err);
setError("Failed to load users: " + err.message);
setUsers([]);
})
.finally(() => setLoading(false));
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
return { users, loading, error, reload: load };
}
export function useVerifyUserFull(id) {
const accessToken = useAuthStore((s) => s.accessToken);
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
setLoading(true);
setError("");
log("useVerifyUserFull: fetching user full data for id", id);
fetchVerifyUserFull(accessToken, id)
.then((data) => {
log("useVerifyUserFull: fetched user data", data);
setUserData(data);
setError("");
})
.catch((err) => {
log("useVerifyUserFull: error fetching user data", err);
setError("Failed to fetch user data: " + err.message);
setUserData(null);
})
.finally(() => setLoading(false));
}, [accessToken, id]);
return { userData, loading, error };
}
export function useVerifyUserDocuments(id) {
const accessToken = useAuthStore((s) => s.accessToken);
const [docsData, setDocsData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
setLoading(true);
setError("");
log("useVerifyUserDocuments: fetching user documents for id", id);
fetchVerifyUserDocuments(accessToken, id)
.then((data) => {
log("useVerifyUserDocuments: fetched docs data", data);
setDocsData(data);
setError("");
})
.catch((err) => {
log("useVerifyUserDocuments: error fetching docs", err);
setError("Failed to fetch user documents: " + err.message);
setDocsData(null);
})
.finally(() => setLoading(false));
}, [accessToken, id]);
return { docsData, loading, error };
}

View File

@ -0,0 +1,91 @@
import React, { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useVerifyUserFull, useVerifyUserDocuments } from "../hooks/useVerifyUser";
import { useTranslation } from "react-i18next";
import PageLayout from "../../../PageLayout";
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
import VerifyUserInformation from "../components/VerifyUserInformation";
import VerifyUserDocuments from "../components/VerifyUserDocuments";
import VerifyUserSetPermission from "../components/VerifyUserSetPermission";
function VerifyUser() {
const { id } = useParams();
const navigate = useNavigate();
const { t } = useTranslation('verify_user');
const { userData, loading: loadingUser, error: errorUser } = useVerifyUserFull(id);
const { docsData, loading: loadingDocs, error: errorDocs } = useVerifyUserDocuments(id);
const loading = loadingUser || loadingDocs;
const err = errorUser || errorDocs;
if (loading) {
return (
<PageLayout showHeader showFooter>
<GlobalAnimatedBackground />
<div className="flex justify-center items-center min-h-screen">
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
<span className="text-blue-600 font-bold text-xl">{t('headings.loadingUser')}</span>
</div>
</div>
</PageLayout>
);
}
if (err || !userData) {
return (
<PageLayout showHeader showFooter>
<GlobalAnimatedBackground />
<div className="flex justify-center items-center min-h-screen">
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
<span className="text-red-600 font-bold text-xl">{err || t('headings.userNotFound')}</span>
<button
className="mt-6 px-4 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700"
onClick={() => navigate(-1)}
>
{t('buttons.back')}
</button>
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout showHeader showFooter>
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
<GlobalAnimatedBackground />
<main className="relative z-10 flex justify-center px-1 sm:px-8 w-full">
<div
className="rounded-2xl shadow-lg p-3 sm:p-8 w-full bg-white"
style={{
maxWidth: "1600px",
marginTop: "0.5%",
marginBottom: "2%",
width: "100%",
}}
>
<button
className="mb-6 px-4 py-2 bg-blue-50 text-blue-700 rounded hover:bg-blue-100"
onClick={() => navigate(-1)}
>
{t('buttons.back')}
</button>
<h1 className="text-2xl font-bold text-blue-900 mb-6 text-center">
{t('headings.verifyUser')}
</h1>
{/* User Info/Profile/Status */}
<VerifyUserInformation userData={userData} />
{/* Media/Documents */}
<VerifyUserDocuments docsData={docsData} />
{/* Keep VerifyUserSetPermission as is */}
<div className="my-8">
<VerifyUserSetPermission userData={userData} />
</div>
</div>
</main>
</div>
</PageLayout>
);
}
export default VerifyUser;

View File

@ -0,0 +1,53 @@
import React, { useState } from "react";
import VerifyUserListFilterForm from "../components/VerifyUserListFilterForm";
import VerifyUserList from "../components/VerifyUserList";
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
import PageLayout from "../../../PageLayout";
import { useTranslation } from "react-i18next";
function VerifyUserQueue() {
const [filters, setFilters] = useState({});
const [pageSize, setPageSize] = useState(10);
const { t } = useTranslation('verify_user');
return (
<PageLayout showHeader={true} showFooter={true}>
<GlobalAnimatedBackground />
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
{/* Remove all extra vertical padding/margin from parent containers */}
<main className="flex-1 flex justify-center items-start px-1 sm:px-8 w-full">
<div
className="rounded-lg shadow-lg p-3 sm:p-8 bg-gray-100 w-full"
style={{
marginTop: "0.5%", // Match dashboard/referral management top margin
marginBottom: "2%",
width: "100%",
maxWidth: "1600px",
}}
>
<h1 className="text-2xl sm:text-4xl font-extrabold text-blue-900 mb-2 text-center tracking-tight">
{t('headings.queueTitle')}
</h1>
<p className="text-xs sm:text-base text-blue-900 text-center font-semibold mb-4 sm:mb-8">
{t('headings.queueSubtitle')}
</p>
<div className="bg-white rounded-lg p-2 sm:p-4 shadow-lg w-full">
<VerifyUserListFilterForm
filters={filters}
setFilters={setFilters}
pageSize={pageSize}
setPageSize={setPageSize}
/>
<VerifyUserList
filters={filters}
pageSize={pageSize}
/>
</div>
</div>
</main>
</div>
</PageLayout>
);
}
export default VerifyUserQueue;

View File

@ -0,0 +1,47 @@
import { authFetch } from "../../../utils/authFetch";
import { log } from "../../../utils/logger";
export async function fetchUserStatusApi() {
log("fetchUserStatusApi called");
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/user/status`,
{ method: "GET", credentials: "include" }
);
if (!res.ok) {
log("fetchUserStatusApi failed with status:", res.status);
throw new Error("Failed to fetch user status");
}
const json = await res.json();
log("fetchUserStatusApi success, response:", json);
return json;
}
export async function fetchUserApi() {
log("fetchUserApi called");
const res = await authFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/me`,
{ method: "GET", credentials: "include" }
);
if (!res.ok) {
log("fetchUserApi failed with status:", res.status);
throw new Error("Failed to fetch user");
}
const json = await res.json();
log("fetchUserApi success, response:", json);
return json;
}
export async function refreshTokenApi() {
log("refreshTokenApi called");
const res = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/refresh`,
{ method: "POST", credentials: "include" }
);
if (!res.ok) {
log("refreshTokenApi failed with status:", res.status);
throw new Error("Failed to refresh token");
}
const json = await res.json();
log("refreshTokenApi success, response:", json);
return json;
}

View File

@ -0,0 +1,84 @@
import React from "react";
import { useTranslation } from "react-i18next";
export default function InformationSection() {
const { t } = useTranslation("dashboard");
return (
<div className="mb-8">
{/* Latest News */}
<div className="bg-blue-50 border border-blue-200 rounded-lg shadow p-6 mb-6">
<h3 className="text-xl font-bold text-blue-900 mb-3">
{t("latestNews.title")}
</h3>
<ul className="list-disc pl-5 text-blue-800 text-lg space-y-3">
<li>
<strong>{t("latestNews.new")}</strong>{" "}
{t("latestNews.coffeeMachinePrefix")}
<a
href="https://www.profit-planet.shop/collections/vitapresso/products/vitapresso-coffee-capsel-abo"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 underline hover:text-blue-900 text-lg"
>
{t("latestNews.coffeeMachineLink")}
</a>{" "}
{t("latestNews.coffeeMachineSuffix")}
</li>
<li>
🚀 <strong>{t("latestNews.referralTitle")}</strong>{" "}
{t("latestNews.referralDesc")}
</li>
<li>
🛒 {t("latestNews.webshopDesc")}
</li>
</ul>
</div>
{/* Enhanced Webshop Card */}
<div className="bg-blue-50 border border-blue-200 rounded-2xl shadow-lg p-8 flex flex-col sm:flex-row items-center justify-between mb-2">
<div className="flex-1">
<h4 className="text-xl font-bold text-blue-900 mb-3">
{t("webshop.title")}
</h4>
<p className="text-blue-800 text-lg mb-4">
{t("webshop.desc")}
</p>
<a
href="https://www.profit-planet.shop/"
target="_blank"
rel="noopener noreferrer"
className="inline-block px-6 py-3 bg-blue-600 text-white text-lg font-semibold rounded-lg shadow hover:bg-blue-700 transition"
>
{t("webshop.button")}
</a>
</div>
<div className="hidden sm:flex items-center justify-center ml-8">
<svg
className="w-20 h-20 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 48 48"
>
<rect
x="10"
y="18"
width="28"
height="16"
rx="6"
fill="#dbeafe"
stroke="#3b82f6"
strokeWidth="2"
/>
<path
stroke="#3b82f6"
strokeWidth="2"
d="M14 18V14a4 4 0 014-4h12a4 4 0 014 4v4"
/>
<circle cx="18" cy="34" r="3" fill="#3b82f6" />
<circle cx="30" cy="34" r="3" fill="#3b82f6" />
</svg>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
import React from "react";
import { useTranslation } from "react-i18next";
function RegisterAdminMessage({ userStatus, user }) {
const { t } = useTranslation(["dashboard"]);
if (!userStatus || !user) {
console.log("⚠️ RegisterAdminMessage: Missing userStatus or user data");
return null;
}
console.log("🎯 RegisterAdminMessage: Checking display conditions with status:", userStatus);
// Map the real API status to our expected format
const status = {
emailVerified: userStatus.email_verified === 1,
idDocumentProvided: userStatus.documents_uploaded === 1,
additionalInfoComplete: userStatus.profile_completed === 1,
contractSigned: userStatus.contract_signed === 1,
isAdminVerified: userStatus.is_admin_verified === 1,
};
console.log("📊 RegisterAdminMessage: Mapped status:", status);
// Only show if all steps are completed but admin verification is still pending
const allStepsCompleted =
status.emailVerified &&
status.idDocumentProvided &&
status.additionalInfoComplete &&
status.contractSigned;
const adminVerificationPending = !status.isAdminVerified;
const shouldShow = allStepsCompleted && adminVerificationPending;
console.log("🔍 RegisterAdminMessage: Display conditions:", {
allStepsCompleted,
adminVerificationPending,
shouldShow,
});
if (!shouldShow) {
console.log("❌ RegisterAdminMessage: Conditions not met, hiding component");
return null;
}
console.log("✅ RegisterAdminMessage: Showing admin approval message");
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg shadow-sm p-6 mb-8 w-full">
<div className="flex items-center">
<svg
className="w-8 h-8 text-yellow-400 mr-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"
/>
</svg>
<div>
<h4 className="text-xl font-bold text-yellow-800 mb-2">
{t("admin.pendingApprovalTitle")}
</h4>
<p className="text-yellow-700 text-lg">
{t("admin.pendingApprovalDesc")}
</p>
</div>
</div>
</div>
);
}
export default RegisterAdminMessage;

View File

@ -0,0 +1,191 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { log } from "../../../utils/logger";
function RegisterQuickAction({ userStatus, user }) {
const navigate = useNavigate();
const { t } = useTranslation(['dashboard','common']);
const [showContractInfo, setShowContractInfo] = useState(false);
if (!userStatus || !user) {
log("⚠️ RegisterQuickAction: Missing userStatus or user data");
return null;
}
log("🎯 RegisterQuickAction: Rendering with status:", userStatus);
// Map the real API status to our expected format
const status = {
emailVerified: userStatus.email_verified === 1,
idDocumentProvided: userStatus.documents_uploaded === 1,
additionalInfoComplete: userStatus.profile_completed === 1,
contractSigned: userStatus.contract_signed === 1,
isAdminVerified: userStatus.is_admin_verified === 1
};
log("📊 RegisterQuickAction: Mapped status:", status);
// If all steps are completed, don't show quick actions
const allStepsCompleted = status.emailVerified && status.idDocumentProvided &&
status.additionalInfoComplete && status.contractSigned;
if (allStepsCompleted) {
log("✅ RegisterQuickAction: All steps completed, hiding component");
return null;
}
// Helper function to get the correct route based on user type
const getRoute = (actionType) => {
const userType = user.userType;
const routes = {
personal: {
'verify-email': '/personal-verify-email',
'upload-id': '/personal-id-upload',
'complete-profile': '/personal-complete-profile',
'sign-contract': '/personal-sign-contract'
},
company: {
'verify-email': '/company-verify-email',
'upload-id': '/company-id-upload',
'complete-profile': '/company-complete-profile',
'sign-contract': '/company-sign-contract'
}
};
return routes[userType]?.[actionType] || '/dashboard';
};
// Helper function to handle navigation
const handleNavigation = (actionType, label) => {
const route = getRoute(actionType);
log(`🧭 RegisterQuickAction: Navigating to ${route} for action: ${label}`);
navigate(route);
};
// Define the available actions based on completion status
const actions = [];
// Email verification - show if not verified
if (!status.emailVerified) {
actions.push({
key: "verify-email",
label: t('dashboard:actions.verifyEmail'),
color: "#dc2626", // red
hoverColor: "#b91c1c",
onClick: () => handleNavigation("verify-email", t('dashboard:actions.verifyEmail')),
disabled: false
});
}
// ID Document upload - show if not uploaded
if (!status.idDocumentProvided) {
actions.push({
key: "upload-id",
label: user.userType === "personal" ? t('dashboard:actions.uploadIdDocument') : t('dashboard:actions.uploadContactId'),
color: "#2563eb", // blue
hoverColor: "#1d4ed8",
onClick: () => handleNavigation("upload-id", t('dashboard:actions.uploadIdDocument')),
disabled: false
});
}
// Profile completion - show if not completed
if (!status.additionalInfoComplete) {
actions.push({
key: "complete-profile",
label: t('dashboard:actions.completeProfile'),
color: "#2563eb", // blue
hoverColor: "#1d4ed8",
onClick: () => handleNavigation("complete-profile", t('dashboard:actions.completeProfile')),
disabled: false
});
}
// Contract signing - show if not signed
let contractActionDisabled = false;
if (!status.contractSigned) {
const canSignContract = status.emailVerified && status.idDocumentProvided && status.additionalInfoComplete;
contractActionDisabled = !canSignContract;
actions.push({
key: "sign-contract",
label: t('dashboard:actions.signContract'),
color: canSignContract ? "#7c3aed" : "#a3a3a3", // purple or gray
hoverColor: canSignContract ? "#6d28d9" : "#a3a3a3",
onClick: canSignContract
? () => handleNavigation("sign-contract", t('dashboard:actions.signContract'))
: () => setShowContractInfo(true),
disabled: contractActionDisabled
});
}
log("🎮 RegisterQuickAction: Available actions:", actions.map(a => a.label));
if (actions.length === 0) {
log("⚠️ RegisterQuickAction: No actions available, hiding component");
return null;
}
return (
<div className="bg-white border border-blue-100 rounded-xl shadow-lg p-8 mb-8 w-full">
<div className="flex items-center mb-6">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 mr-4">
<svg
className="w-7 h-7 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"
/>
</svg>
</div>
<div>
<p className="text-blue-700 mt-1 text-xl font-bold">
{t('headings.quickActions')}
</p>
</div>
</div>
{/* Responsive button layout */}
<div className={`flex flex-col gap-3 w-full sm:flex-row sm:gap-4 ${actions.length === 1 ? 'justify-center' : ''}`}>
{actions.map((action) => (
<div key={action.key} className="w-full sm:flex-1">
<button
type="button"
className={`w-full font-semibold py-4 text-lg rounded-lg shadow transition focus:outline-none ${action.disabled ? "opacity-60 cursor-not-allowed" : ""}`}
style={{ background: action.color, color: "#fff" }}
onMouseOver={e => !action.disabled && (e.currentTarget.style.background = action.hoverColor)}
onMouseOut={e => e.currentTarget.style.background = action.color}
onClick={action.onClick}
disabled={action.disabled}
>
{action.label}
</button>
{/* Feedback message for contract button if disabled */}
{action.key === "sign-contract" && action.disabled && (
<div className="mt-2 text-center text-red-600 font-medium text-sm">
{t('dashboard:actions.signContractBlocked', {
defaultValue: "You need to complete the previous steps (email verification, ID upload, profile completion) before signing the contract."
})}
</div>
)}
</div>
))}
</div>
{/* Feedback message for contract button */}
{showContractInfo && (
<div className="mt-4 text-center text-red-600 font-medium">
{t('dashboard:actions.signContractBlocked', {
defaultValue: "You need to complete the previous steps (email verification, ID upload, profile completion) before signing the contract."
})}
</div>
)}
</div>
);
}
export default RegisterQuickAction;

View File

@ -0,0 +1,195 @@
import React from "react";
import { useTranslation } from "react-i18next";
// Modern, Google-like status card
function StatusCard({ icon, label, value, bgColor, textColor }) {
return (
<div className={`rounded-2xl shadow-md flex flex-col items-center justify-center py-7 px-4 transition hover:shadow-lg`} style={{ backgroundColor: bgColor }}>
<div className="flex items-center justify-center w-12 h-12 rounded-full mb-3" style={{ backgroundColor: bgColor }}>
{icon}
</div>
<div className="text-center">
<div className="text-base font-medium mb-1" style={{ color: textColor }}>{label}</div>
<div className="text-lg font-bold" style={{ color: textColor }}>{value}</div>
</div>
</div>
);
}
// Compact card for mobile
function CompactStatusCard({ icon, label, value, bgColor, textColor }) {
return (
<div
className="rounded-lg flex items-center px-3 py-2 mb-2 shadow-sm"
style={{
backgroundColor: bgColor,
minHeight: "44px",
fontSize: "0.95rem"
}}
>
<div className="flex items-center justify-center w-7 h-7 rounded-full mr-3" style={{ backgroundColor: bgColor }}>
{icon}
</div>
<div className="flex-1">
<div className="font-medium" style={{ color: textColor }}>{label}</div>
<div className="font-bold" style={{ color: textColor }}>{value}</div>
</div>
</div>
);
}
function StatusSection({ status, user }) {
const { t } = useTranslation(['dashboard','common']);
if (!status || !user) {
console.log("⚠️ StatusSection: Missing status or user data");
return null;
}
// If nothing is uploaded, all are missing
const nothingUploaded =
status.email_verified !== 1 &&
status.documents_uploaded !== 1 &&
status.profile_completed !== 1 &&
status.contract_signed !== 1;
// Map the real API status to our expected format
const mappedStatus = {
emailVerified: nothingUploaded ? false : status.email_verified === 1,
idDocumentProvided: nothingUploaded ? false : status.documents_uploaded === 1,
additionalInfoComplete: nothingUploaded ? false : status.profile_completed === 1,
contractSigned: nothingUploaded ? false : status.contract_signed === 1,
};
// Card data for each status
const cards = [
{
label: t('dashboard:labels.emailVerification'),
value: mappedStatus.emailVerified ? t('common:status.verified') : t('common:status.missing'),
bgColor: mappedStatus.emailVerified ? "#E8F5E9" : "#FFEBEE", // green or red
textColor: mappedStatus.emailVerified ? "#34A853" : "#EA4335",
icon: (
mappedStatus.emailVerified ? (
<svg className="w-7 h-7" fill="none" stroke="#34A853" strokeWidth="3" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-7 h-7" fill="none" stroke="#EA4335" strokeWidth="3" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="#EA4335" strokeWidth="2" />
<path strokeLinecap="round" strokeLinejoin="round" stroke="#EA4335" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
)
),
},
{
label: user.userType === "personal" ? t('dashboard:labels.idDocument') : t('dashboard:labels.contactId'),
value: mappedStatus.idDocumentProvided ? t('common:status.uploaded') : t('common:status.missing'),
bgColor: mappedStatus.idDocumentProvided ? "#E8F5E9" : "#FFEBEE",
textColor: mappedStatus.idDocumentProvided ? "#34A853" : "#EA4335",
icon: (
mappedStatus.idDocumentProvided ? (
<svg className="w-7 h-7" fill="none" stroke="#34A853" strokeWidth="3" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-7 h-7" fill="none" stroke="#EA4335" strokeWidth="3" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="#EA4335" strokeWidth="2" />
<path strokeLinecap="round" strokeLinejoin="round" stroke="#EA4335" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
)
),
},
{
label: t('dashboard:labels.additionalInfo'),
value: mappedStatus.additionalInfoComplete ? t('common:status.complete') : t('common:status.missing'),
bgColor: mappedStatus.additionalInfoComplete ? "#E8F5E9" : "#FFEBEE",
textColor: mappedStatus.additionalInfoComplete ? "#34A853" : "#EA4335",
icon: (
mappedStatus.additionalInfoComplete ? (
<svg className="w-7 h-7" fill="none" stroke="#34A853" strokeWidth="3" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-7 h-7" fill="none" stroke="#EA4335" strokeWidth="3" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="#EA4335" strokeWidth="2" />
<path strokeLinecap="round" strokeLinejoin="round" stroke="#EA4335" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
)
),
},
{
label: t('dashboard:labels.contract'),
value: mappedStatus.contractSigned ? t('common:status.signed') : t('common:status.missing'),
bgColor: mappedStatus.contractSigned ? "#E8F5E9" : "#FFEBEE",
textColor: mappedStatus.contractSigned ? "#34A853" : "#EA4335",
icon: (
mappedStatus.contractSigned ? (
<svg className="w-7 h-7" fill="none" stroke="#34A853" strokeWidth="3" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-7 h-7" fill="none" stroke="#EA4335" strokeWidth="3" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="#EA4335" strokeWidth="2" />
<path strokeLinecap="round" strokeLinejoin="round" stroke="#EA4335" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
)
),
},
];
// Responsive grid columns and padding
const isMobile = window.innerWidth < 640;
return (
<div
className={`bg-white border border-blue-100 rounded-xl shadow-lg w-full`}
style={{
padding: isMobile ? '0.5rem' : '2rem',
marginBottom: isMobile ? '0.5rem' : '2rem',
boxShadow: isMobile ? 'none' : undefined,
borderRadius: isMobile ? '0.5rem' : '1rem',
}}
>
<h3
className="text-xl font-bold text-blue-900 mb-4"
style={{
marginBottom: isMobile ? '0.5rem' : '1.5rem',
fontSize: isMobile ? '1.1rem' : undefined,
}}
>
{t('dashboard:headings.statusOverview')}
</h3>
<div className="w-full">
{isMobile ? (
<div>
{cards.map((card, idx) => (
<CompactStatusCard
key={idx}
icon={card.icon}
label={<span>{card.label}</span>}
value={<span>{card.value}</span>}
bgColor={card.bgColor}
textColor={card.textColor}
/>
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-6 w-full">
{cards.map((card, idx) => (
<StatusCard
key={idx}
icon={card.icon}
label={<span className="text-lg">{card.label}</span>}
value={<span className="text-lg font-bold">{card.value}</span>}
bgColor={card.bgColor}
textColor={card.textColor}
/>
))}
</div>
)}
</div>
</div>
);
}
export default StatusSection;

View File

@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import useAuthStore from "../../../store/authStore";
import { fetchUserStatusApi, fetchUserApi, refreshTokenApi } from "../api/dashboardApi";
import { authFetch } from "../../../utils/authFetch";
import { log } from "../../../utils/logger";
export function useDashboard() {
const { accessToken, user, setAccessToken, setUser, clearAuth } = useAuthStore();
const navigate = useNavigate();
const [userStatus, setUserStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// Fetch user status from API
const fetchUserStatus = async () => {
if (!user) return;
log("useDashboard: fetchUserStatus called for user:", user?.email || user?.id);
try {
const statusData = await fetchUserStatusApi();
log("useDashboard: fetched user status:", statusData);
setUserStatus(statusData.status || statusData);
} catch (err) {
log("useDashboard: error fetching user status:", err);
setUserStatus({
emailVerified: false,
idDocumentProvided: false,
additionalInfoComplete: false,
contractSigned: false,
});
}
};
// Initial authentication and user fetch
useEffect(() => {
async function checkAuth() {
log("useDashboard: checkAuth called. accessToken:", accessToken, "user:", user);
if (!accessToken) {
try {
const data = await refreshTokenApi();
log("useDashboard: refreshed token and user:", data);
setAccessToken(data.accessToken);
setUser(data.user);
} catch (err) {
log("useDashboard: failed to refresh token, clearing auth.", err);
clearAuth();
navigate("/login");
return;
}
}
if (accessToken && !user) {
try {
const userData = await fetchUserApi();
log("useDashboard: fetched user data:", userData);
setUser(userData.user || userData);
} catch (err) {
log("useDashboard: error fetching user data:", err);
}
}
setIsLoading(false);
}
checkAuth();
// eslint-disable-next-line
}, []);
// Fetch user status when user is available
useEffect(() => {
if (user && !userStatus) {
log("useDashboard: user available, fetching status.");
fetchUserStatus();
}
// eslint-disable-next-line
}, [user]);
// Utility functions
const getDisplayName = () => {
if (!user) return "Loading...";
if (user.userType === "personal") {
return `${user.firstName || ""} ${user.lastName || ""}`.trim() || "User";
}
return user.companyName || user.name || user.email || "User";
};
const shouldShowAdminMessage = () => {
if (!userStatus) return false;
const status = {
emailVerified: userStatus.email_verified === 1,
idDocumentProvided: userStatus.documents_uploaded === 1,
additionalInfoComplete: userStatus.profile_completed === 1,
contractSigned: userStatus.contract_signed === 1,
isAdminVerified: userStatus.is_admin_verified === 1,
};
const allStepsCompleted =
status.emailVerified &&
status.idDocumentProvided &&
status.additionalInfoComplete &&
status.contractSigned;
const adminVerificationPending = !status.isAdminVerified;
return allStepsCompleted && adminVerificationPending;
};
const shouldShowQuickActions = () => {
if (!userStatus || !user) return false;
const status = {
emailVerified: userStatus.email_verified === 1,
idDocumentProvided: userStatus.documents_uploaded === 1,
additionalInfoComplete: userStatus.profile_completed === 1,
contractSigned: userStatus.contract_signed === 1,
};
const allStepsCompleted =
status.emailVerified &&
status.idDocumentProvided &&
status.additionalInfoComplete &&
status.contractSigned;
return !allStepsCompleted;
};
return {
accessToken,
user,
userStatus,
isLoading,
getDisplayName,
shouldShowAdminMessage,
shouldShowQuickActions,
};
}

View File

@ -0,0 +1,151 @@
import React from "react";
import PageLayout from "../../PageLayout";
import StatusSection from "../components/StatusSection";
import RegisterQuickAction from "../components/RegisterQuickAction";
import RegisterAdminMessage from "../components/RegisterAdminMessage";
import GlobalAnimatedBackground from "../../../background/GlobalAnimatedBackground";
import { useDashboard } from "../hooks/useDashboard";
import { useTranslation } from "react-i18next";
import InformationSection from "../components/InformationSection";
import Footer from "../../nav/Footer"; // Import Footer
export default function Dashboard() {
const {
accessToken,
user,
userStatus,
isLoading,
getDisplayName,
shouldShowAdminMessage,
shouldShowQuickActions,
} = useDashboard();
const { t } = useTranslation(['dashboard','common']);
if (isLoading) {
return (
<PageLayout showHeader={true} showFooter={true}>
<div className="min-h-screen w-full flex items-center justify-center">
<div className="text-lg">{t('common:status.loading')}</div>
</div>
</PageLayout>
);
}
if (!user) return null;
return (
<PageLayout showHeader={true} showFooter={window.innerWidth >= 640}>
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
<GlobalAnimatedBackground variant="dashboard" />
<div className="relative z-10 w-full flex justify-center">
{/* Mobile: remove bg-gray-100 and inner wrapper, move Footer inside */}
{window.innerWidth < 640 ? (
<div className="w-full" style={{ margin: 0, padding: 0 }}>
<div
className="rounded-none shadow-none p-0 bg-white w-full"
style={{
margin: 0,
borderRadius: 0,
boxShadow: 'none',
maxWidth: "100vw",
width: "100vw",
minHeight: "100vh",
display: "flex",
flexDirection: "column",
justifyContent: "stretch",
paddingBottom: 0
}}
>
<h2
className="text-3xl font-extrabold text-blue-900 mb-3 text-center tracking-tight"
style={{
marginTop: '1rem'
}}
>
{t('headings.welcome', { name: getDisplayName() })}
</h2>
<p className="text-lg text-blue-900 text-center font-semibold mb-6">
{user.userType === "personal" ? t('accountType.personal') : t('accountType.company')}
</p>
<div className="bg-white rounded-none p-0 shadow-none">
<div className="mb-6" />
{/* Status Overview */}
{userStatus && (
<div className="mb-6 w-full">
<div className="bg-blue-50 rounded-lg p-3 shadow-md w-full">
<StatusSection status={userStatus} user={user} />
</div>
</div>
)}
{/* Quick Actions */}
{shouldShowQuickActions() && (
<div className="bg-blue-50 rounded-lg p-3 shadow-md w-full mb-8">
<RegisterQuickAction userStatus={userStatus} user={user} />
</div>
)}
{/* Admin Message */}
{shouldShowAdminMessage() && (
<div className="mt-6 bg-yellow-50 rounded-lg p-3 shadow-md w-full mb-6">
<RegisterAdminMessage userStatus={userStatus} user={user} />
</div>
)}
{/* Information Section */}
<InformationSection />
</div>
{/* Move Footer here for mobile */}
<Footer />
</div>
</div>
) : (
<div
className="flex justify-center py-2 px-1 sm:px-4 w-full"
style={{
padding: undefined,
margin: '0',
}}
>
<div
className="rounded-lg shadow-lg p-4 sm:p-8 bg-gray-100 w-full"
style={{
margin: '0',
borderRadius: undefined,
boxShadow: undefined,
maxWidth: "1600px",
width: "100%",
}}
>
<h2 className="text-3xl sm:text-6xl font-extrabold text-blue-900 mb-3 text-center tracking-tight">
{t('headings.welcome', { name: getDisplayName() })}
</h2>
<p className="text-lg sm:text-xl text-blue-900 text-center font-semibold mb-6 sm:mb-10">
{user.userType === "personal" ? t('accountType.personal') : t('accountType.company')}
</p>
<div className="bg-white rounded-lg p-2 sm:p-4 shadow-lg">
<div className="mb-6 sm:mb-10" />
{userStatus && (
<div className="mb-6 sm:mb-10 w-full">
<div className="bg-blue-50 rounded-lg p-3 sm:p-6 shadow-md w-full">
<StatusSection status={userStatus} user={user} />
</div>
</div>
)}
{shouldShowQuickActions() && (
<div className="bg-blue-50 rounded-lg p-3 sm:p-6 shadow-md w-full mb-8">
<RegisterQuickAction userStatus={userStatus} user={user} />
</div>
)}
{shouldShowAdminMessage() && (
<div className="mt-6 sm:mt-10 bg-yellow-50 rounded-lg p-3 sm:p-6 shadow-md w-full mb-8">
<RegisterAdminMessage userStatus={userStatus} user={user} />
</div>
)}
<InformationSection />
</div>
</div>
</div>
)}
</div>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,32 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import Footer from "../../nav/Footer";
import GlobalAnimatedBackground from "../../../background/GlobalAnimatedBackground";
const NotFoundPage = () => {
const navigate = useNavigate();
return (
<div className="relative flex flex-col min-h-screen overflow-hidden">
<GlobalAnimatedBackground />
<div className="flex-grow flex flex-col items-center justify-center z-10 relative">
<div className="bg-white rounded-xl shadow-2xl px-10 py-12 max-w-md w-full text-center">
<h1 className="text-7xl font-extrabold text-blue-600 mb-4">404</h1>
<h2 className="text-3xl font-bold text-gray-800 mb-2">Page Not Found</h2>
<p className="text-gray-500 mb-6">
Sorry, the page you are looking for does not exist or has been moved.
</p>
<button
onClick={() => navigate("/login")}
className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow hover:bg-blue-700 transition"
>
Go back to Login
</button>
</div>
</div>
<Footer />
</div>
);
};
export default NotFoundPage;

View File

@ -0,0 +1,92 @@
import { getCurrentLanguage } from "../../../i18n/i18n";
import { log } from "../../../utils/logger";
export async function loginApi(email, password) {
// Extended diagnostics
const viteEnv = (import.meta && import.meta.env) ? import.meta.env : {};
const viteVars = Object.fromEntries(
Object.entries(viteEnv).filter(([k]) => k.startsWith('VITE_'))
);
log('loginApi VITE_* keys detected:', Object.keys(viteVars));
log('loginApi raw import.meta.env.VITE_API_BASE_URL:', viteEnv.VITE_API_BASE_URL);
// Multi-source fallback resolution
const candidates = [
[viteEnv.VITE_API_BASE_URL, 'import.meta.env'],
[globalThis.VITE_API_BASE_URL, 'globalThis.VITE_API_BASE_URL'],
[globalThis.__ENV__?.VITE_API_BASE_URL, 'globalThis.__ENV__.VITE_API_BASE_URL'],
[typeof document !== 'undefined' ? document.querySelector('meta[name="vite-api-base-url"]')?.getAttribute('content') : undefined, 'meta tag'],
[typeof process !== 'undefined' ? process.env?.VITE_API_BASE_URL : undefined, 'process.env (build-time only in Vite)']
];
let resolved = { value: undefined, source: null };
for (const [val, src] of candidates) {
if (val) { resolved = { value: val, source: src }; break; }
}
const rawBase = resolved.value;
log(`loginApi resolved base:`, rawBase, 'source:', resolved.source);
if (!rawBase) {
log('[loginApi] VITE_API_BASE_URL unresolved. Ensure one of:');
log(' - .env(.local) contains VITE_API_BASE_URL=...');
log(' - Rebuilt after setting it (PM2 uses built assets, variables are NOT injected at runtime).');
log(' - If runtime injection desired, expose window.VITE_API_BASE_URL or meta[name="vite-api-base-url"].');
throw new Error('Missing VITE_API_BASE_URL');
}
const normalizedBase = rawBase.replace(/\/+$/, '');
log('loginApi normalized base:', normalizedBase);
const endpoint = /\/api$/i.test(normalizedBase)
? normalizedBase + '/auth/login'
: normalizedBase + '/api/auth/login';
log('loginApi endpoint:', endpoint);
function maskEmail(e) {
if (!e) return '';
const [u, d] = e.split('@');
if (!d || !u) return '***';
return u[0] + '***@' + d;
}
const start = performance.now();
const lang = getCurrentLanguage();
log(`[loginApi] starting POST ${endpoint} email=${maskEmail(email)} t=${start.toFixed(2)}ms`);
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password, lang }),
});
const end = performance.now();
const elapsed = end - start;
const contentType = (res.headers.get('content-type') || '').toLowerCase();
log(`[loginApi] response meta status=${res.status} ok=${res.ok} content-type=${contentType} elapsed=${elapsed.toFixed(2)}ms`);
if (!contentType.includes('application/json')) {
try {
const snippet = (await res.clone().text()).slice(0, 200);
log(`[loginApi] Non-JSON content-type (${contentType || 'none'}). First 200 chars: ${JSON.stringify(snippet)}. Possible wrong VITE_API_BASE_URL or endpoint.`);
} catch (e) {
log('[loginApi] Failed to read non-JSON body snippet:', e);
}
}
let data;
try {
log('[loginApi] JSON parse starting');
data = await res.json();
} catch (err) {
log('[loginApi] JSON parse error:', err);
throw new Error('Non-JSON response');
}
if (!res.ok) {
log('[loginApi] API error payload:', data);
throw new Error("Invalid credentials");
}
log('[loginApi] success keys:', Array.isArray(data) ? 'array(length=' + data.length + ')' : Object.keys(data).join(','));
return data;
}

View File

@ -0,0 +1,277 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { useLogin } from '../hooks/useLogin';
import { loginSchema } from '../../../validation/login/loginSchema';
import { useTranslation } from 'react-i18next';
import { useNavigate } from "react-router-dom";
function LoginForm() {
const [showPassword, setShowPassword] = useState(false);
const [showBall, setShowBall] = useState(true);
// React Hook Form setup
const {
register,
handleSubmit,
formState: { errors }
} = useForm({
resolver: yupResolver(loginSchema)
});
const { login, error: loginError, setError: setLoginError, loading } = useLogin();
const { t } = useTranslation('auth');
const navigate = useNavigate();
useEffect(() => {
const handleResize = () => {
setShowBall(window.innerWidth >= 768);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const onSubmit = async (data) => {
setLoginError("");
await login(data.email, data.password);
};
return (
<div
className="w-full flex justify-center items-center min-h-screen py-8 relative"
style={{
minHeight: 'calc(100vh - 100px)',
paddingTop: window.innerWidth < 768 ? '0.25rem' : '5rem', // Even less top padding for mobile
paddingBottom: window.innerWidth < 768 ? '2.5rem' : '2.5rem',
}}
>
<div
className="bg-white rounded-2xl shadow-2xl flex flex-col items-center relative border-t-4 border-blue-600"
style={{
width: window.innerWidth < 768 ? '98vw' : '40vw', // Wider on mobile
maxWidth: window.innerWidth < 768 ? 'none' : '700px',
minWidth: window.innerWidth < 768 ? '0' : '400px',
minHeight: window.innerWidth < 768 ? '320px' : '320px',
padding: window.innerWidth < 768 ? '0.5rem' : '2rem',
marginTop: window.innerWidth < 768 ? '0.5rem' : undefined,
transform: window.innerWidth < 768 ? undefined : 'scale(0.85)',
transformOrigin: 'top center',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start'
}}
>
{/* Ball: mittig, überlappend, nur auf Desktop sichtbar using showBall state */}
{showBall && ( // Use showBall state here
<div className="absolute -top-16 left-1/2 -translate-x-1/2 w-28 z-20">
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-200 to-blue-400 flex items-center justify-center shadow-xl border-4 border-white relative">
<svg className="w-20 h-20 text-blue-400" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="20" fill="currentColor" />
<ellipse cx="32" cy="38" rx="16" ry="5" fill="#2563eb" fillOpacity=".10" />
<ellipse cx="32" cy="26" rx="10" ry="4" fill="#2563eb" fillOpacity=".08" />
<circle cx="40" cy="26" r="3" fill="#2563eb" fillOpacity=".5" />
</svg>
{/* Orbiting balls */}
<span className="absolute left-1/2 top-1/2 w-0 h-0">
<span className="block absolute animate-orbit-true" style={{ width: 0, height: 0 }}>
<span className="block w-3 h-3 bg-blue-400 rounded-full shadow-lg" style={{ transform: 'translateX(44px)' }}></span>
</span>
<span className="block absolute animate-orbit-true2" style={{ width: 0, height: 0 }}>
<span className="block w-2.5 h-2.5 bg-teal-400 rounded-full shadow-md" style={{ transform: 'translateX(-36px)' }}></span>
</span>
</span>
</div>
</div>
)}
{/* Move content up inside the form */}
<div style={{
marginTop: window.innerWidth < 768 ? '0.5rem' : '1.5rem', // Move Profit Planet title up
marginBottom: window.innerWidth < 768 ? '1.5rem' : '2rem', // More space at bottom
width: '100%'
}}>
<h1
className="mb-2 text-center text-4xl font-extrabold text-blue-900 tracking-tight drop-shadow-lg"
style={{
fontSize: window.innerWidth < 768 ? '2rem' : undefined,
marginTop: window.innerWidth < 768 ? '0.5rem' : undefined, // Move title up
}}
>
Profit Planet
</h1>
<p
className="mb-8 text-center text-lg text-blue-500 font-medium"
style={{
fontSize: window.innerWidth < 768 ? '0.95rem' : undefined,
marginBottom: window.innerWidth < 768 ? '1rem' : undefined,
}}
>
{t('login.tagline')}
</p>
<form
className="space-y-7 w-full"
style={{
// Reduced gap for mobile
gap: window.innerWidth < 768 ? '0.75rem' : undefined,
}}
onSubmit={handleSubmit(onSubmit)}
>
<div>
<label
htmlFor="email"
className="block text-base font-semibold text-blue-900 mb-1"
style={{
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for label
marginBottom: window.innerWidth < 768 ? '0.25rem' : undefined, // Reduced margin
}}
>
{t('login.emailLabel')}
</label>
<input
id="email"
type="email"
autoComplete="email"
className="appearance-none block w-full px-4 py-3 border border-blue-200 rounded-lg placeholder-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 text-base bg-white text-blue-900 transition"
style={{
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for input
padding: window.innerWidth < 768 ? '0.4rem 0.75rem' : undefined, // Smaller padding for input
}}
{...register('email')}
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label
htmlFor="password"
className="block text-base font-semibold text-blue-900 mb-1"
style={{
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for label
marginBottom: window.innerWidth < 768 ? '0.25rem' : undefined, // Reduced margin
}}
>
{t('login.passwordLabel')}
</label>
<input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
className="appearance-none block w-full px-4 py-3 border border-blue-200 rounded-lg placeholder-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 text-base bg-white text-blue-900 transition"
style={{
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for input
padding: window.innerWidth < 768 ? '0.4rem 0.75rem' : undefined, // Smaller padding for input
}}
{...register('password')}
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
)}
<div className="mt-2 flex items-center">
<input
id="show-password"
type="checkbox"
className="h-4 w-4 border-black border-2 rounded focus:ring-blue-500 bg-transparent" // Transparent background with black border
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
/>
<label htmlFor="show-password" className="ml-2 text-sm text-gray-700">{t('login.showPassword')}</label>
</div>
</div>
{loginError && (
<div
className="text-red-500 text-base"
style={{
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined,
}}
>
{/* Attempt translation; fallback to original message */}
{t(loginError, { defaultValue: loginError })}
</div>
)}
<div>
<button
type="submit"
className="w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white bg-gradient-to-r from-blue-700 via-blue-500 to-blue-400 hover:from-blue-800 hover:to-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 transition-all duration-200 transform hover:-translate-y-0.5"
style={{
fontSize: window.innerWidth < 768 ? '0.9rem' : undefined, // Slightly smaller font size for button
padding: window.innerWidth < 768 ? '0.6rem 1rem' : undefined, // Smaller padding for button
}}
disabled={loading} // Disable button while loading
>
{loading ? t('login.signingIn') : t('login.signIn')}
</button>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
className="text-blue-600 hover:underline text-sm font-medium"
onClick={() => navigate("/password-reset")}
>
{t('login.forgotPassword', { defaultValue: "Forgot password?" })}
</button>
</div>
</form>
<div
className="mt-10 w-full"
style={{
marginTop: window.innerWidth < 768 ? '1rem' : undefined,
}}
>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-blue-100"></div>
</div>
<div
className="relative flex justify-center text-base"
style={{
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for "Need an account?"
}}
>
<span className="px-3 bg-white text-blue-400">{t('login.needAccount')}</span>
</div>
</div>
<div
className="mt-7 text-center"
style={{
marginTop: window.innerWidth < 768 ? '0.75rem' : undefined,
}}
>
<p
className="text-base text-blue-500"
style={{
fontSize: window.innerWidth < 768 ? '0.8rem' : undefined, // Smaller font size for description
}}
>
{t('login.inviteOnlyDescription')}
</p>
<p
className="text-base text-blue-400 mt-2"
style={{
fontSize: window.innerWidth < 768 ? '0.8rem' : undefined, // Smaller font size for contact info
}}
>
{t('login.contactSupport')}
</p>
</div>
</div>
</div>
{/* Animations for orbiting balls */}
<style>{`
@keyframes orbit-true {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes orbit-true2 {
0% { transform: rotate(0deg); }
100% { transform: rotate(-360deg); }
}
.animate-orbit-true { animation: orbit-true 3s linear infinite; transform-origin: 0 0; }
.animate-orbit-true2 { animation: orbit-true2 4s linear infinite; transform-origin: 0 0; }
`}</style>
</div>
</div>
);
}
export default LoginForm;

View File

@ -0,0 +1,39 @@
import { useState } from "react";
import useAuthStore from "../../../store/authStore";
import { useNavigate } from "react-router-dom";
import { showToast } from "../../toast/toastUtils";
import { loginApi } from "../api/loginApi";
import { log } from "../../../utils/logger";
export function useLogin() {
const setAccessToken = useAuthStore((s) => s.setAccessToken);
const setUser = useAuthStore((s) => s.setUser);
const navigate = useNavigate();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const login = async (email, password) => {
setError("");
setLoading(true);
log("useLogin: login called", { email });
try {
const data = await loginApi(email, password);
log("useLogin: login success", { user: data.user });
setAccessToken(data.accessToken);
setUser(data.user);
sessionStorage.setItem("userType", data.user.userType);
sessionStorage.setItem("role", data.user.role || "user");
showToast({ type: "success", message: "Login successful!" });
navigate("/dashboard");
} catch (err) {
log("useLogin: login error", err);
setError(err.message || "An error occurred. Please try again.");
showToast({ type: "error", message: err.message || "An error occurred. Please try again." });
} finally {
setLoading(false);
}
};
return { login, error, setError, loading };
}

View File

@ -0,0 +1,36 @@
import React, { useState, useEffect } from 'react';
import LoginForm from '../components/LoginForm'
import PageLayout from '../../PageLayout'
import GlobalAnimatedBackground from '../../../background/GlobalAnimatedBackground' // Re-import the animation component
import Footer from '../../nav/Footer'
function Login() {
const [showBackground, setShowBackground] = useState(() => window.innerWidth >= 768);
useEffect(() => {
const handleResize = () => setShowBackground(window.innerWidth >= 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<PageLayout showHeader={true} showFooter={false}>
<div className="relative w-screen flex flex-col">
{/* Animated background behind everything */}
{showBackground && (
<div className="absolute inset-0 z-0">
<GlobalAnimatedBackground />
</div>
)}
<div className="relative z-10 flex-1 flex items-center justify-center py-12 px-4 overflow-hidden">
<div className="w-full">
<LoginForm />
</div>
</div>
<Footer />
</div>
</PageLayout>
)
}
export default Login

View File

@ -0,0 +1,11 @@
import React from "react";
function Footer() {
return (
<footer className="w-full bg-white border-t border-gray-200 py-6 mt-12 text-center text-sm text-gray-500 z-10 relative">
&copy; {new Date().getFullYear()} Profit Planet. All rights reserved.
</footer>
);
}
export default Footer;

478
src/features/nav/Header.jsx Normal file
View File

@ -0,0 +1,478 @@
import React, { useState, useRef, useEffect } from "react";
import useAuthStore from "../../store/authStore";
import { useNavigate } from "react-router-dom";
import { showToast } from "../toast/toastUtils";
import LanguageSwitcher from "./LanguageSwitcher"; // added
import { log } from "../../utils/logger";
// Modern, sleek navbar: left-aligned logo/title, right-aligned nav/profile
function Header() {
const [open, setOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [adminDropdownOpen, setAdminDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
const mobileMenuRef = useRef(null);
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout); // use logout from store
const navigate = useNavigate();
// Close dropdown when clicking outside
useEffect(() => {
function handleClick(e) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClick);
}
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Close mobile menu when clicking outside
useEffect(() => {
function handleClick(e) {
if (
mobileMenuRef.current &&
!mobileMenuRef.current.contains(e.target) &&
!e.target.closest(".mobile-hamburger")
) {
setMobileMenuOpen(false);
}
}
if (mobileMenuOpen) {
document.addEventListener("mousedown", handleClick);
}
return () => document.removeEventListener("mousedown", handleClick);
}, [mobileMenuOpen]);
const handleLogout = async () => {
log("🚪 Header: User logout initiated");
try {
log("🌐 Header: Calling Zustand logout (will call /api/auth/logout)");
await logout();
log("✅ Header: Zustand logout completed");
showToast({ type: "success", tKey: "toast:logoutSuccess" });
log("🧭 Header: Redirecting to login");
navigate("/login");
} catch (err) {
log("❌ Header: Error during logout:", err);
showToast({ type: "error", message: "Logout failed" });
}
};
// Helper to check permission
const canAccessReferrals =
user &&
(
user.role === "admin" ||
user.role === "super_admin" ||
(Array.isArray(user.permissions) && user.permissions.includes("can_create_referrals"))
);
// Helper to get profile/settings route
const getProfileRoute = () =>
user?.userType === "company" ? "/company/profile" : "/personal/profile";
const getSettingsRoute = () =>
user?.userType === "company" ? "/company/settings" : "/personal/settings";
const handleProfileClick = (e) => {
e.preventDefault();
navigate(getProfileRoute());
setOpen(false);
};
const handleSettingsClick = (e) => {
e.preventDefault();
navigate(getSettingsRoute());
setOpen(false);
};
// Helper to get user initials for profile icon
const getUserInitials = () => {
if (!user) return "U";
if (user.userType === "company" && user.companyName) {
return user.companyName
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
if (user.firstName || user.lastName) {
return (
(user.firstName?.[0] || "") +
(user.lastName?.[0] || "")
).toUpperCase();
}
if (user.name) {
return user.name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
if (user.email) {
return user.email[0].toUpperCase();
}
return "U";
};
// Helper to check if user is admin
const isAdmin = sessionStorage.getItem("role") === "admin";
return (
<header className="w-full bg-white border-b border-gray-200 shadow-sm">
<div className="flex items-center justify-center w-full">
<div className="w-full max-w-[1600px] flex items-center justify-between px-0 py-3 md:px-6">
{/* Left: SVG Logo */}
<div className="flex items-center gap-3 pl-4 md:pl-6">
<button
type="button"
className="w-32 h-14 flex items-center md:w-40 focus:outline-none"
onClick={() => navigate("/dashboard")}
aria-label="Go to dashboard"
style={{ background: "none", border: "none", padding: 0 }}
>
<img
src="/img/profitplanet_logo.svg"
alt="Profit Planet Logo"
className="h-9 w-auto md:h-10 transition-transform duration-300 ease-in-out hover:scale-110"
draggable="false"
/>
</button>
</div>
{/* If not logged in, only show language switcher */}
{!user && (
<div className="flex items-center pr-4 md:pr-6 ml-auto">
<div className="hidden md:block">
<LanguageSwitcher />
</div>
<div className="md:hidden">
<LanguageSwitcher compact />
</div>
</div>
)}
{/* Desktop Navigation + Profile (only when authenticated) */}
{user && (
<div className="hidden md:flex items-center gap-6">
<nav className="flex items-center gap-4">
<a
href="/dashboard"
className="px-3 py-1 rounded-lg text-blue-900 font-medium hover:bg-blue-100 hover:text-blue-700 transition"
>
Dashboard
</a>
{canAccessReferrals && (
<a
href="/referral-management"
className="px-3 py-1 rounded-lg text-blue-900 font-medium hover:bg-blue-100 hover:text-blue-700 transition"
>
Referrals
</a>
)}
{/* Admin dropdown */}
{isAdmin && (
<div className="relative group">
<button
className="px-3 py-1 rounded-lg text-blue-900 font-medium hover:bg-blue-100 hover:text-blue-700 transition flex items-center gap-1"
type="button"
>
Admin
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M19 9l-7 7-7-7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className="absolute left-0 mt-2 bg-white rounded-xl shadow-xl border border-blue-100 z-50 min-w-[200px] opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-opacity">
{/* Add Admin Dashboard as first entry */}
<a
href="/admin/dashboard"
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
>
Admin Dashboard
</a>
<a
href="/admin/user-management"
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
>
User Management
</a>
<a
href="/admin/permission-management"
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
>
Permission Management
</a>
<a
href="/admin/verify-users"
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
>
Verify Users
</a>
{/* Add Contract Dashboard link */}
<a
href="/admin/contract-dashboard"
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
>
Contract Dashboard
</a>
</div>
</div>
)}
</nav>
<LanguageSwitcher /> {/* restored for logged-in desktop users */}
{/* Profile Icon + Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
className="avatar avatar-placeholder focus:outline-none"
onClick={() => setOpen((v) => !v)}
aria-label="Open profile menu"
>
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-gradient-to-br from-blue-100 via-blue-200 to-blue-300 text-blue-900 shadow-lg border-2 border-blue-200">
<span className="text-base font-semibold">{getUserInitials()}</span>
</div>
</button>
{open && (
<div
className="absolute right-0 mt-2 bg-white rounded-xl shadow-xl border border-blue-100 z-50"
style={{
minWidth: "14rem",
width: "max-content",
maxWidth: "90vw"
}}
>
<div className="px-6 py-4 border-b border-gray-100">
<div className="font-bold text-blue-900 text-lg break-words">
{user?.name || user?.companyName || "Test User"}
</div>
<div className="text-sm text-gray-500 break-words">
{user?.email || "test@test.com"}
</div>
</div>
<div className="flex flex-col py-2">
<a
href={getProfileRoute()}
className="px-6 py-2 text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition cursor-pointer"
onClick={handleProfileClick}
>
Profile
</a>
<button
type="button"
className="px-6 py-2 text-red-600 font-semibold hover:bg-red-50 hover:text-red-700 text-left transition cursor-pointer"
onClick={handleLogout}
>
Logout
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Mobile Hamburger + Language Switcher (when authenticated) */}
{user && (
<div className="md:hidden flex items-center pr-4 gap-2">
{/* Language switcher next to hamburger */}
<LanguageSwitcher compact />
<button
className="mobile-hamburger flex flex-col justify-center items-center w-10 h-10 rounded focus:outline-none"
aria-label="Open menu"
onClick={() => setMobileMenuOpen((v) => !v)}
>
<span className={`block w-7 h-0.5 bg-blue-900 mb-1 transition-all duration-300 ${mobileMenuOpen ? "rotate-45 translate-y-2" : ""}`}></span>
<span className={`block w-7 h-0.5 bg-blue-900 mb-1 transition-all duration-300 ${mobileMenuOpen ? "opacity-0" : ""}`}></span>
<span className={`block w-7 h-0.5 bg-blue-900 transition-all duration-300 ${mobileMenuOpen ? "-rotate-45 -translate-y-2" : ""}`}></span>
</button>
</div>
)}
</div>
</div>
{/* Mobile Slide Menu */}
{user && (
<div
ref={mobileMenuRef}
className={`fixed top-0 left-0 h-full w-full sm:w-80 bg-white/80 backdrop-blur-md shadow-2xl z-[999]
transition-transform duration-300 md:hidden
${mobileMenuOpen ? "translate-x-0" : "-translate-x-full"}
${mobileMenuOpen ? "animate-slide-in" : "animate-slide-out"}
`}
style={{ willChange: "transform" }}
>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-2 py-3 border-b border-gray-100">
<span className="flex items-center ml-1">
<img
src="/img/profitplanet_logo.svg"
alt="Profit Planet Logo"
className="h-5 w-auto"
draggable="false"
/>
</span>
<button
className="flex items-center gap-1 text-blue-900 text-xl focus:outline-none mr-1"
aria-label="Close menu"
onClick={() => setMobileMenuOpen(false)}
>
<span className="text-2xl">&times;</span>
</button>
</div>
{/* Remove language switcher from inside mobile menu */}
<nav className="flex flex-col gap-0 px-6 py-4">
<a
href="/dashboard"
className="py-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition border-b border-blue-200/30"
onClick={() => setMobileMenuOpen(false)}
>
Dashboard
</a>
{canAccessReferrals && (
<a
href="/referral-management"
className="py-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition border-b border-blue-200/30"
onClick={() => setMobileMenuOpen(false)}
>
Referrals
</a>
)}
{/* Admin mobile dropdown */}
{isAdmin && (
<div>
<button
type="button"
className="py-2 w-full text-left text-blue-900 font-medium hover:bg-blue-100 rounded transition border-b border-blue-200/30 flex items-center justify-between"
onClick={() => setAdminDropdownOpen((v) => !v)}
aria-expanded={adminDropdownOpen}
>
<span className="flex items-center gap-2">
Admin
<svg className="w-4 h-4 ml-1 inline" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M19 9l-7 7-7-7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
{/* Show dropdown if open, with slide animation */}
<div
className={`
overflow-hidden
transition-all duration-300
${adminDropdownOpen ? "max-h-96 animate-admin-slide-in" : "max-h-0 animate-admin-slide-out"}
`}
style={{ willChange: "max-height" }}
>
{adminDropdownOpen && (
<div className="ml-4 flex flex-col bg-white rounded shadow border border-blue-100 mt-1 mb-2">
<a
href="/admin/dashboard"
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
>
Admin Dashboard
</a>
<a
href="/admin/user-management"
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
>
User Management
</a>
<a
href="/admin/permission-management"
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
>
Permission Management
</a>
<a
href="/admin/verify-users"
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
>
Verify Users
</a>
{/* Add Contract Dashboard link */}
<a
href="/admin/contract-dashboard"
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
>
Contract Dashboard
</a>
</div>
)}
</div>
</div>
)}
<a
href={getProfileRoute()}
className="py-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition border-b border-blue-200/30"
onClick={(e) => {
e.preventDefault();
navigate(getProfileRoute());
setMobileMenuOpen(false);
}}
>
Profile
</a>
<button
type="button"
className="py-2 text-red-600 font-semibold hover:bg-red-50 hover:text-red-700 rounded text-left transition border-b border-blue-200/30"
onClick={async () => {
await handleLogout();
setMobileMenuOpen(false);
}}
>
Logout
</button>
</nav>
<div className="mt-auto px-6 py-4 text-xs text-blue-300">
&copy; {new Date().getFullYear()} Profit Planet
</div>
</div>
</div>
)}
{/* Overlay for mobile menu */}
{user && mobileMenuOpen && (
<div
className="fixed inset-0 z-[998] md:hidden pointer-events-none"
></div>
)}
{/* Visual separation below header */}
<div className="w-full h-2 bg-gradient-to-b from-gray-100 to-transparent"></div>
{/* Animation styles */}
<style>{`
@keyframes slideInMenu {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes slideOutMenu {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
.animate-slide-in {
animation: slideInMenu 0.3s cubic-bezier(0.4,0,0.2,1) forwards;
}
.animate-slide-out {
animation: slideOutMenu 0.3s cubic-bezier(0.4,0,0.2,1) forwards;
}
@keyframes adminSlideIn {
from { max-height: 0; opacity: 0; }
to { max-height: 500px; opacity: 1; }
}
@keyframes adminSlideOut {
from { max-height: 500px; opacity: 1; }
to { max-height: 0; opacity: 0; }
}
.animate-admin-slide-in {
animation: adminSlideIn 0.3s cubic-bezier(0.4,0,0.2,1) forwards;
}
.animate-admin-slide-out {
animation: adminSlideOut 0.3s cubic-bezier(0.4,0,0.2,1) forwards;
}
`}</style>
</header>
);
}
export default Header;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getCurrentLanguage, setLanguage } from '../../i18n/i18n';
// Props: compact (bool) for mobile usage
export default function LanguageSwitcher({ compact = false }) {
const { t, i18n } = useTranslation('common');
const current = i18n.language || getCurrentLanguage();
const handleChange = (e) => {
setLanguage(e.target.value);
};
const baseClass =
'border rounded-lg bg-white text-blue-900 focus:outline-none focus:ring-2 focus:ring-blue-300 transition';
return (
<div
className={compact ? 'mt-2 flex items-center gap-2' : 'flex items-center gap-2'}
style={{ minWidth: compact ? 'auto' : '140px' }}
>
{!compact && (
<label
htmlFor={`lang-switcher-${compact ? 'c' : 'd'}`}
className="text-sm text-gray-600"
>
{t('language.switcher.label')}
</label>
)}
<select
id={`lang-switcher-${compact ? 'c' : 'd'}`}
value={current}
onChange={handleChange}
className={
baseClass +
' text-sm px-2 py-1 ' +
(compact ? 'h-8' : 'h-9')
}
aria-label={t('language.switcher.label')}
>
<option value="en">{t('language.switcher.english')}</option>
<option value="de">{t('language.switcher.german')}</option>
</select>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { log } from "../../../utils/logger";
export async function requestPasswordResetApi(email) {
log("requestPasswordResetApi called", { email });
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email })
});
if (!res.ok) {
let errorData;
try {
errorData = await res.json();
log("requestPasswordResetApi error response:", errorData);
} catch (e) {
errorData = {};
log("requestPasswordResetApi error parsing response:", e);
}
const error = new Error(errorData.error || "Request failed");
error.status = res.status;
error.data = errorData;
log("requestPasswordResetApi failed:", error);
throw error;
}
log("requestPasswordResetApi success");
return res;
}
export async function verifyPasswordResetTokenApi(token) {
log("verifyPasswordResetTokenApi called", { token });
return fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/verify-password-reset?token=${encodeURIComponent(token)}`);
}
export async function resetPasswordApi(token, newPassword) {
log("resetPasswordApi called", { token, newPassword: !!newPassword });
return fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword })
});
}

View File

@ -0,0 +1,70 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { showToast } from "../../toast/toastUtils";
import { requestPasswordResetApi } from "../api/usePasswordResetApi";
import { useNavigate } from "react-router-dom";
export default function PasswordResetForm() {
const { t } = useTranslation("passwordReset");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState("");
const [error, setError] = useState("");
const navigate = useNavigate();
const handleRequest = async (e) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess("");
try {
await requestPasswordResetApi(email);
setSuccess(t("success.resetRequested")); // passwordReset:success.resetRequested
showToast({ type: "success", tKey: "toast:success.resetRequested" });
setTimeout(() => navigate("/login", { replace: true }), 1500);
} catch (err) {
if (
err?.status === 429 ||
err?.message?.toLowerCase().includes("rate limit") ||
err?.data?.error === "RATE_LIMITED"
) {
setError(t("error.rateLimited")); // passwordReset:error.rateLimited
showToast({ type: "error", tKey: "toast:passwordResetRateLimited" });
setTimeout(() => navigate("/login", { replace: true }), 1500);
} else {
setError(t("error.generic")); // passwordReset:error.generic
showToast({ type: "error", tKey: "toast:passwordResetGenericError" });
setTimeout(() => navigate("/login", { replace: true }), 1500);
}
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleRequest} className="space-y-6" aria-live="polite">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("label.email")}
</label>
<input
type="email"
className="w-full px-4 py-3 rounded-lg border border-gray-200 bg-gray-50 text-blue-900 placeholder-gray-400"
placeholder={t("placeholder.email")}
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<button
type="submit"
className="w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white bg-gradient-to-r from-blue-700 via-blue-500 to-blue-400 hover:from-blue-800 hover:to-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 transition-all duration-200"
disabled={loading}
>
{loading ? t("button.sending") : t("button.sendLink")}
</button>
{success && <p className="mt-4 text-center text-green-500 text-sm" aria-live="polite">{success}</p>}
{error && <p className="mt-4 text-center text-red-500 text-sm" aria-live="polite">{error}</p>}
</form>
);
}

View File

@ -0,0 +1,150 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { validatePassword } from "../hooks/usePasswordReset";
import { resetPasswordApi } from "../api/usePasswordResetApi";
import { showToast } from "../../toast/toastUtils";
import { useNavigate } from "react-router-dom";
export default function PasswordResetSetNewPasswordForm({ token }) {
const { t } = useTranslation("passwordReset");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState("");
const [error, setError] = useState("");
const navigate = useNavigate();
const rules = validatePassword(newPassword);
const handleReset = async (e) => {
e.preventDefault();
setError("");
setSuccess("");
if (!rules.valid) {
setError(t("error.passwordLength"));
return;
}
if (newPassword !== confirmPassword) {
setError(t("error.passwordsNoMatch"));
return;
}
setLoading(true);
try {
const res = await resetPasswordApi(token, newPassword);
const data = await res.json();
if (data.success) {
setSuccess(t("success.reset"));
showToast({ type: "success", message: t("success.reset") });
setTimeout(() => navigate("/login", { replace: true }), 1500);
} else {
setError(t("error.resetFailed"));
showToast({ type: "error", message: t("error.resetFailed") });
}
} catch {
setError(t("error.resetFailed"));
showToast({ type: "error", message: t("error.resetFailed") });
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleReset} className="space-y-6" aria-live="polite">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("label.newPassword")}
</label>
<input
type={showPassword ? "text" : "password"}
className="w-full px-4 py-3 rounded-lg border border-gray-200 bg-gray-50 text-blue-900 placeholder-gray-400"
placeholder={t("placeholder.newPassword")}
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
minLength={8}
required
aria-describedby="password-guideline"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("label.confirmPassword")}
</label>
<input
type={showPassword ? "text" : "password"}
className="w-full px-4 py-3 rounded-lg border border-gray-200 bg-gray-50 text-blue-900 placeholder-gray-400"
placeholder={t("placeholder.confirmPassword")}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
minLength={8}
required
/>
</div>
<div className="flex items-center gap-2 mb-2">
<input
id="show-password"
type="checkbox"
checked={showPassword}
onChange={e => setShowPassword(e.target.checked)}
className="accent-blue-600"
aria-checked={showPassword}
/>
<label htmlFor="show-password" className="text-sm text-gray-700">
{t("label.showPassword")}
</label>
</div>
<div
id="password-guideline"
className="border-l-4 border-blue-300 bg-blue-50 p-3 mb-2 rounded text-sm"
aria-live="polite"
>
<div className="font-semibold mb-2 text-blue-700">{t("guideline.title")}</div>
<ul className="space-y-1">
<PasswordRule
passed={rules.length}
text={t("guideline.length")}
/>
<PasswordRule
passed={rules.lowercase}
text={t("guideline.lowercase")}
/>
<PasswordRule
passed={rules.uppercase}
text={t("guideline.uppercase")}
/>
<PasswordRule
passed={rules.number}
text={t("guideline.number")}
/>
<PasswordRule
passed={rules.special}
text={t("guideline.special")}
/>
</ul>
</div>
<button
type="submit"
className="w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white bg-gradient-to-r from-blue-700 via-blue-500 to-blue-400 hover:from-blue-800 hover:to-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 transition-all duration-200"
disabled={loading || !rules.valid || newPassword !== confirmPassword}
aria-disabled={loading || !rules.valid || newPassword !== confirmPassword}
>
{loading ? t("button.resetting") : t("button.reset")}
</button>
{success && <p className="mt-4 text-center text-green-500 text-sm" aria-live="polite">{success}</p>}
{error && <p className="mt-4 text-center text-red-500 text-sm" aria-live="polite">{error}</p>}
</form>
);
}
function PasswordRule({ passed, text }) {
return (
<li className={`flex items-center gap-2 ${passed ? "text-green-600" : "text-gray-500"}`}>
{passed ? (
<span aria-label="passed" role="img"></span>
) : (
<span aria-label="not passed" role="img"></span>
)}
<span>{text}</span>
</li>
);
}

View File

@ -0,0 +1,19 @@
import { log } from "../../../utils/logger";
export function validatePassword(password) {
const length = password.length >= 8;
const lowercase = /[a-z]/.test(password);
const uppercase = /[A-Z]/.test(password);
const number = /[0-9]/.test(password);
const special = /[^A-Za-z0-9]/.test(password);
const result = {
valid: length && lowercase && uppercase && number && special,
length,
lowercase,
uppercase,
number,
special
};
log("validatePassword called", { passwordLength: password.length, result });
return result;
}

Some files were not shown because too many files have changed in this diff Show More