Initial Commit
This commit is contained in:
commit
b3acaef775
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
101
README.md
Normal 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
29
eslint.config.js
Normal 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
15
index.html
Normal 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
7977
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
package.json
Normal file
55
package.json
Normal 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
13
postcss.config.mjs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
179
public/css/fallback/register.css
Normal file
179
public/css/fallback/register.css
Normal 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;
|
||||
}
|
||||
1023
public/img/profit_planet_favicon.svg
Normal file
1023
public/img/profit_planet_favicon.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 66 KiB |
BIN
public/img/profitplanet_logo.png
Normal file
BIN
public/img/profitplanet_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
53
public/img/profitplanet_logo.svg
Normal file
53
public/img/profitplanet_logo.svg
Normal 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
15
public/index.html
Normal 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
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
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
4244
public/pdf/pdf.sandbox.mjs
Normal file
File diff suppressed because one or more lines are too long
1
public/pdf/pdf.sandbox.mjs.map
Normal file
1
public/pdf/pdf.sandbox.mjs.map
Normal file
File diff suppressed because one or more lines are too long
64492
public/pdf/pdf.worker.mjs
Normal file
64492
public/pdf/pdf.worker.mjs
Normal file
File diff suppressed because one or more lines are too long
1
public/pdf/pdf.worker.mjs.map
Normal file
1
public/pdf/pdf.worker.mjs.map
Normal file
File diff suppressed because one or more lines are too long
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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 |
0
scripts/updateInventory.js
Normal file
0
scripts/updateInventory.js
Normal file
42
src/App.css
Normal file
42
src/App.css
Normal 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
36
src/App.jsx
Normal 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
405
src/AppRouter.jsx
Normal 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
1
src/assets/react.svg
Normal 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 |
41
src/auth/AdminAuthMiddleware.jsx
Normal file
41
src/auth/AdminAuthMiddleware.jsx
Normal 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
186
src/auth/AuthWrapper.jsx
Normal 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;
|
||||
43
src/auth/CompanyAuthMiddleware.jsx
Normal file
43
src/auth/CompanyAuthMiddleware.jsx
Normal 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
44
src/auth/OtpForm.jsx
Normal 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;
|
||||
43
src/auth/PersonalAuthMiddleware.jsx
Normal file
43
src/auth/PersonalAuthMiddleware.jsx
Normal 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;
|
||||
20
src/auth/ProtectedRoute.jsx
Normal file
20
src/auth/ProtectedRoute.jsx
Normal 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;
|
||||
109
src/background/GlobalAnimatedBackground.jsx
Normal file
109
src/background/GlobalAnimatedBackground.jsx
Normal 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;
|
||||
54
src/background/GlobalMobileBackground.jsx
Normal file
54
src/background/GlobalMobileBackground.jsx
Normal 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;
|
||||
42
src/features/PageLayout.jsx
Normal file
42
src/features/PageLayout.jsx
Normal 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;
|
||||
110
src/features/RouteProtection.jsx
Normal file
110
src/features/RouteProtection.jsx
Normal 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;
|
||||
62
src/features/admin/adminDashboard/api/adminDashboardApi.js
Normal file
62
src/features/admin/adminDashboard/api/adminDashboardApi.js
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
101
src/features/admin/adminDashboard/hooks/useAdminDashboard.js
Normal file
101
src/features/admin/adminDashboard/hooks/useAdminDashboard.js
Normal 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 };
|
||||
}
|
||||
75
src/features/admin/adminDashboard/pages/AdminDashboard.jsx
Normal file
75
src/features/admin/adminDashboard/pages/AdminDashboard.jsx
Normal 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;
|
||||
@ -0,0 +1,7 @@
|
||||
// Dummy hook for future API integration
|
||||
const useContractDashboardApi = () => {
|
||||
// Will fetch documents from backend later
|
||||
return {};
|
||||
};
|
||||
|
||||
export default useContractDashboardApi;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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 wrap—save 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">• 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;
|
||||
@ -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;
|
||||
971
src/features/admin/contractDashboard/pages/contractDashboard.jsx
Normal file
971
src/features/admin/contractDashboard/pages/contractDashboard.jsx
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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 };
|
||||
}
|
||||
@ -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;
|
||||
46
src/features/admin/userManagement/api/adminUserProfileApi.js
Normal file
46
src/features/admin/userManagement/api/adminUserProfileApi.js
Normal 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;
|
||||
}
|
||||
104
src/features/admin/userManagement/api/userManagementApi.js
Normal file
104
src/features/admin/userManagement/api/userManagementApi.js
Normal 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;
|
||||
}
|
||||
@ -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.
|
||||
406
src/features/admin/userManagement/components/AdminUserList.jsx
Normal file
406
src/features/admin/userManagement/components/AdminUserList.jsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
201
src/features/admin/userManagement/components/PermissionModal.jsx
Normal file
201
src/features/admin/userManagement/components/PermissionModal.jsx
Normal 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;
|
||||
117
src/features/admin/userManagement/hooks/useAdminUserProfile.js
Normal file
117
src/features/admin/userManagement/hooks/useAdminUserProfile.js
Normal 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,
|
||||
};
|
||||
}
|
||||
124
src/features/admin/userManagement/hooks/useUserManagement.js
Normal file
124
src/features/admin/userManagement/hooks/useUserManagement.js
Normal 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 };
|
||||
}
|
||||
158
src/features/admin/userManagement/pages/AdminUserProfilePage.jsx
Normal file
158
src/features/admin/userManagement/pages/AdminUserProfilePage.jsx
Normal 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;
|
||||
62
src/features/admin/userManagement/pages/UserManagement.jsx
Normal file
62
src/features/admin/userManagement/pages/UserManagement.jsx
Normal 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;
|
||||
|
||||
68
src/features/admin/verifyUser/api/verifyUserApi.js
Normal file
68
src/features/admin/verifyUser/api/verifyUserApi.js
Normal 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;
|
||||
}
|
||||
499
src/features/admin/verifyUser/components/VerifyUserDocuments.jsx
Normal file
499
src/features/admin/verifyUser/components/VerifyUserDocuments.jsx
Normal 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;
|
||||
@ -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;
|
||||
317
src/features/admin/verifyUser/components/VerifyUserList.jsx
Normal file
317
src/features/admin/verifyUser/components/VerifyUserList.jsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
95
src/features/admin/verifyUser/hooks/useVerifyUser.js
Normal file
95
src/features/admin/verifyUser/hooks/useVerifyUser.js
Normal 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 };
|
||||
}
|
||||
91
src/features/admin/verifyUser/pages/VerifyUser.jsx
Normal file
91
src/features/admin/verifyUser/pages/VerifyUser.jsx
Normal 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;
|
||||
53
src/features/admin/verifyUser/pages/VerifyUserQueue.jsx
Normal file
53
src/features/admin/verifyUser/pages/VerifyUserQueue.jsx
Normal 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;
|
||||
47
src/features/dashboard/api/dashboardApi.js
Normal file
47
src/features/dashboard/api/dashboardApi.js
Normal 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;
|
||||
}
|
||||
84
src/features/dashboard/components/InformationSection.jsx
Normal file
84
src/features/dashboard/components/InformationSection.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/features/dashboard/components/RegisterAdminMessage.jsx
Normal file
77
src/features/dashboard/components/RegisterAdminMessage.jsx
Normal 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;
|
||||
191
src/features/dashboard/components/RegisterQuickAction.jsx
Normal file
191
src/features/dashboard/components/RegisterQuickAction.jsx
Normal 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;
|
||||
195
src/features/dashboard/components/StatusSection.jsx
Normal file
195
src/features/dashboard/components/StatusSection.jsx
Normal 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;
|
||||
129
src/features/dashboard/hooks/useDashboard.js
Normal file
129
src/features/dashboard/hooks/useDashboard.js
Normal 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,
|
||||
};
|
||||
}
|
||||
151
src/features/dashboard/pages/Dashboard.jsx
Normal file
151
src/features/dashboard/pages/Dashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/features/error/pages/404.jsx
Normal file
32
src/features/error/pages/404.jsx
Normal 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;
|
||||
92
src/features/login/api/loginApi.js
Normal file
92
src/features/login/api/loginApi.js
Normal 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;
|
||||
}
|
||||
277
src/features/login/components/LoginForm.jsx
Normal file
277
src/features/login/components/LoginForm.jsx
Normal 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;
|
||||
39
src/features/login/hooks/useLogin.js
Normal file
39
src/features/login/hooks/useLogin.js
Normal 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 };
|
||||
}
|
||||
36
src/features/login/pages/Login.jsx
Normal file
36
src/features/login/pages/Login.jsx
Normal 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
|
||||
11
src/features/nav/Footer.jsx
Normal file
11
src/features/nav/Footer.jsx
Normal 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">
|
||||
© {new Date().getFullYear()} Profit Planet. All rights reserved.
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
478
src/features/nav/Header.jsx
Normal file
478
src/features/nav/Header.jsx
Normal 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">×</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">
|
||||
© {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;
|
||||
46
src/features/nav/LanguageSwitcher.jsx
Normal file
46
src/features/nav/LanguageSwitcher.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/features/password-reset/api/usePasswordResetApi.js
Normal file
41
src/features/password-reset/api/usePasswordResetApi.js
Normal 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 })
|
||||
});
|
||||
}
|
||||
70
src/features/password-reset/components/PasswordResetForm.jsx
Normal file
70
src/features/password-reset/components/PasswordResetForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
19
src/features/password-reset/hooks/usePasswordReset.js
Normal file
19
src/features/password-reset/hooks/usePasswordReset.js
Normal 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
Loading…
Reference in New Issue
Block a user