Compare commits
5 Commits
abce06ad67
...
f22f87250c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f22f87250c | ||
|
|
91bc4f190d | ||
|
|
c10c1d1c39 | ||
|
|
dde091138e | ||
|
|
9c72fe87f9 |
@@ -4,7 +4,7 @@ import logging
|
|||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import func, or_, and_, select
|
from sqlalchemy import func, or_, and_, select, case
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -149,15 +149,16 @@ class CRUDOrganization(CRUDBase[Organization, OrganizationCreate, OrganizationUp
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build base query with LEFT JOIN and GROUP BY
|
# Build base query with LEFT JOIN and GROUP BY
|
||||||
|
# Use CASE statement to count only active members
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
Organization,
|
Organization,
|
||||||
func.count(
|
func.count(
|
||||||
func.distinct(
|
func.distinct(
|
||||||
and_(
|
case(
|
||||||
UserOrganization.is_active == True,
|
(UserOrganization.is_active == True, UserOrganization.user_id),
|
||||||
UserOrganization.user_id
|
else_=None
|
||||||
).self_group()
|
)
|
||||||
)
|
)
|
||||||
).label('member_count')
|
).label('member_count')
|
||||||
)
|
)
|
||||||
|
|||||||
204
frontend/package-lock.json
generated
204
frontend/package-lock.json
generated
@@ -9,16 +9,17 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
@@ -3223,6 +3224,52 @@
|
|||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "1.1.15",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
@@ -3329,6 +3376,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
@@ -3395,6 +3460,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-direction": {
|
"node_modules/@radix-ui/react-direction": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
@@ -3534,12 +3617,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-label": {
|
"node_modules/@radix-ui/react-label": {
|
||||||
"version": "2.1.7",
|
"version": "2.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
|
||||||
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-primitive": "2.1.3"
|
"@radix-ui/react-primitive": "2.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -3596,6 +3702,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popover": {
|
"node_modules/@radix-ui/react-popover": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||||
@@ -3633,6 +3757,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
@@ -3736,6 +3878,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-roving-focus": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
@@ -3810,6 +3970,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-separator": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||||
@@ -3834,9 +4012,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
@@ -14243,9 +14421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.65.0",
|
"version": "7.66.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||||
"integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==",
|
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -22,16 +22,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
|
|||||||
@@ -24,13 +24,19 @@ export default function AdminLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AuthGuard requireAdmin>
|
<AuthGuard requireAdmin>
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main id="main-content" className="flex-1 overflow-y-auto">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export default function AdminOrganizationsPage() {
|
|||||||
{/* Back Button + Header */}
|
{/* Back Button + Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin">
|
<Link href="/admin">
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon" aria-label="Back to Admin Dashboard">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function AdminPage() {
|
|||||||
<Link href="/admin/users" className="block">
|
<Link href="/admin/users" className="block">
|
||||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Users className="h-5 w-5 text-primary" />
|
<Users className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||||
<h3 className="font-semibold">User Management</h3>
|
<h3 className="font-semibold">User Management</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -51,7 +51,7 @@ export default function AdminPage() {
|
|||||||
<Link href="/admin/organizations" className="block">
|
<Link href="/admin/organizations" className="block">
|
||||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Building2 className="h-5 w-5 text-primary" />
|
<Building2 className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||||
<h3 className="font-semibold">Organizations</h3>
|
<h3 className="font-semibold">Organizations</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -63,7 +63,7 @@ export default function AdminPage() {
|
|||||||
<Link href="/admin/settings" className="block">
|
<Link href="/admin/settings" className="block">
|
||||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Settings className="h-5 w-5 text-primary" />
|
<Settings className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||||
<h3 className="font-semibold">System Settings</h3>
|
<h3 className="font-semibold">System Settings</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export default function AdminSettingsPage() {
|
|||||||
{/* Back Button + Header */}
|
{/* Back Button + Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin">
|
<Link href="/admin">
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon" aria-label="Back to Admin Dashboard">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { Metadata } from 'next';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { UserManagementContent } from '@/components/admin/users/UserManagementContent';
|
||||||
|
|
||||||
/* istanbul ignore next - Next.js metadata, not executable code */
|
/* istanbul ignore next - Next.js metadata, not executable code */
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -36,26 +37,8 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder Content */}
|
{/* User Management Content */}
|
||||||
<div className="rounded-lg border bg-card p-12 text-center">
|
<UserManagementContent />
|
||||||
<h3 className="text-xl font-semibold mb-2">
|
|
||||||
User Management Coming Soon
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
|
||||||
This page will allow you to view all users, create new accounts,
|
|
||||||
manage permissions, and perform bulk operations.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-4">
|
|
||||||
Features will include:
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
|
|
||||||
<li>• User list with search and filtering</li>
|
|
||||||
<li>• Create/edit/delete user accounts</li>
|
|
||||||
<li>• Activate/deactivate users</li>
|
|
||||||
<li>• Role and permission management</li>
|
|
||||||
<li>• Bulk operations</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
53
frontend/src/app/forbidden/page.tsx
Normal file
53
frontend/src/app/forbidden/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 403 Forbidden Page
|
||||||
|
* Displayed when users try to access resources they don't have permission for
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* istanbul ignore next - Next.js type import for metadata */
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
/* istanbul ignore next - Next.js metadata, not executable code */
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '403 - Forbidden',
|
||||||
|
description: 'You do not have permission to access this resource',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ForbiddenPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-6 py-16">
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||||
|
<div className="mb-8 rounded-full bg-destructive/10 p-6">
|
||||||
|
<ShieldAlert
|
||||||
|
className="h-16 w-16 text-destructive"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-4 text-4xl font-bold tracking-tight">
|
||||||
|
403 - Access Forbidden
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mb-2 text-lg text-muted-foreground max-w-md">
|
||||||
|
You don't have permission to access this resource.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mb-8 text-sm text-muted-foreground max-w-md">
|
||||||
|
This page requires administrator privileges. If you believe you should
|
||||||
|
have access, please contact your system administrator.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button asChild variant="default">
|
||||||
|
<Link href="/dashboard">Go to Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/">Go to Home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,14 +69,14 @@ export function AdminSidebar() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className="rounded-md p-2 hover:bg-accent"
|
className="rounded-md p-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
data-testid="sidebar-toggle"
|
data-testid="sidebar-toggle"
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +96,7 @@ export function AdminSidebar() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
'hover:bg-accent hover:text-accent-foreground',
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
@@ -104,7 +105,7 @@ export function AdminSidebar() {
|
|||||||
title={collapsed ? item.name : undefined}
|
title={collapsed ? item.name : undefined}
|
||||||
data-testid={`nav-${item.name.toLowerCase()}`}
|
data-testid={`nav-${item.name.toLowerCase()}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
<Icon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
|
||||||
{!collapsed && <span>{item.name}</span>}
|
{!collapsed && <span>{item.name}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function DashboardStats() {
|
|||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Failed to load dashboard statistics: {error?.message || 'Unknown error'}
|
Failed to load dashboard statistics: {error?.message || 'Unknown error'}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export function StatCard({
|
|||||||
'h-6 w-6',
|
'h-6 w-6',
|
||||||
loading ? 'text-muted-foreground' : 'text-primary'
|
loading ? 'text-muted-foreground' : 'text-primary'
|
||||||
)}
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
186
frontend/src/components/admin/users/BulkActionToolbar.tsx
Normal file
186
frontend/src/components/admin/users/BulkActionToolbar.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* BulkActionToolbar Component
|
||||||
|
* Toolbar for performing bulk actions on selected users
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CheckCircle, XCircle, Trash, X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useBulkUserAction } from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
|
interface BulkActionToolbarProps {
|
||||||
|
selectedCount: number;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
selectedUserIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkAction = 'activate' | 'deactivate' | 'delete' | null;
|
||||||
|
|
||||||
|
export function BulkActionToolbar({
|
||||||
|
selectedCount,
|
||||||
|
onClearSelection,
|
||||||
|
selectedUserIds,
|
||||||
|
}: BulkActionToolbarProps) {
|
||||||
|
const [pendingAction, setPendingAction] = useState<BulkAction>(null);
|
||||||
|
const bulkAction = useBulkUserAction();
|
||||||
|
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAction = (action: BulkAction) => {
|
||||||
|
setPendingAction(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAction = async () => {
|
||||||
|
if (!pendingAction) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bulkAction.mutateAsync({
|
||||||
|
action: pendingAction,
|
||||||
|
userIds: selectedUserIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Successfully ${pendingAction}d ${selectedCount} user${selectedCount > 1 ? 's' : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
|
onClearSelection();
|
||||||
|
setPendingAction(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : `Failed to ${pendingAction} users`
|
||||||
|
);
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAction = () => {
|
||||||
|
setPendingAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionDescription = () => {
|
||||||
|
switch (pendingAction) {
|
||||||
|
case 'activate':
|
||||||
|
return `Are you sure you want to activate ${selectedCount} user${selectedCount > 1 ? 's' : ''}? They will be able to log in.`;
|
||||||
|
case 'deactivate':
|
||||||
|
return `Are you sure you want to deactivate ${selectedCount} user${selectedCount > 1 ? 's' : ''}? They will not be able to log in until reactivated.`;
|
||||||
|
case 'delete':
|
||||||
|
return `Are you sure you want to delete ${selectedCount} user${selectedCount > 1 ? 's' : ''}? This action cannot be undone.`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionTitle = () => {
|
||||||
|
switch (pendingAction) {
|
||||||
|
case 'activate':
|
||||||
|
return 'Activate Users';
|
||||||
|
case 'deactivate':
|
||||||
|
return 'Deactivate Users';
|
||||||
|
case 'delete':
|
||||||
|
return 'Delete Users';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
|
||||||
|
data-testid="bulk-action-toolbar"
|
||||||
|
>
|
||||||
|
<div className="bg-background border rounded-lg shadow-lg p-4 flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedCount} user{selectedCount > 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
aria-label="Clear selection"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-border" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAction('activate')}
|
||||||
|
disabled={bulkAction.isPending}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAction('deactivate')}
|
||||||
|
disabled={bulkAction.isPending}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAction('delete')}
|
||||||
|
disabled={bulkAction.isPending}
|
||||||
|
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
<AlertDialog open={!!pendingAction} onOpenChange={() => cancelAction()}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{getActionTitle()}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{getActionDescription()}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmAction}
|
||||||
|
className={
|
||||||
|
pendingAction === 'delete'
|
||||||
|
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pendingAction === 'activate' && 'Activate'}
|
||||||
|
{pendingAction === 'deactivate' && 'Deactivate'}
|
||||||
|
{pendingAction === 'delete' && 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
frontend/src/components/admin/users/UserActionMenu.tsx
Normal file
183
frontend/src/components/admin/users/UserActionMenu.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* UserActionMenu Component
|
||||||
|
* Dropdown menu for user row actions (Edit, Activate/Deactivate, Delete)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { MoreHorizontal, Edit, CheckCircle, XCircle, Trash } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
useActivateUser,
|
||||||
|
useDeactivateUser,
|
||||||
|
useDeleteUser,
|
||||||
|
type User,
|
||||||
|
} from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
|
interface UserActionMenuProps {
|
||||||
|
user: User;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
onEdit?: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmAction = 'delete' | 'deactivate' | null;
|
||||||
|
|
||||||
|
export function UserActionMenu({ user, isCurrentUser, onEdit }: UserActionMenuProps) {
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const activateUser = useActivateUser();
|
||||||
|
const deactivateUser = useDeactivateUser();
|
||||||
|
const deleteUser = useDeleteUser();
|
||||||
|
|
||||||
|
const fullName = user.last_name
|
||||||
|
? `${user.first_name} ${user.last_name}`
|
||||||
|
: user.first_name;
|
||||||
|
|
||||||
|
// Handle activate action
|
||||||
|
const handleActivate = async () => {
|
||||||
|
try {
|
||||||
|
await activateUser.mutateAsync(user.id);
|
||||||
|
toast.success(`${fullName} has been activated successfully.`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to activate user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle deactivate action
|
||||||
|
const handleDeactivate = async () => {
|
||||||
|
try {
|
||||||
|
await deactivateUser.mutateAsync(user.id);
|
||||||
|
toast.success(`${fullName} has been deactivated successfully.`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to deactivate user');
|
||||||
|
} finally {
|
||||||
|
setConfirmAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete action
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteUser.mutateAsync(user.id);
|
||||||
|
toast.success(`${fullName} has been deleted successfully.`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to delete user');
|
||||||
|
} finally {
|
||||||
|
setConfirmAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit action
|
||||||
|
const handleEdit = () => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
if (onEdit) {
|
||||||
|
onEdit(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render confirmation dialog
|
||||||
|
const renderConfirmDialog = () => {
|
||||||
|
if (!confirmAction) return null;
|
||||||
|
|
||||||
|
const isDelete = confirmAction === 'delete';
|
||||||
|
const title = isDelete ? 'Delete User' : 'Deactivate User';
|
||||||
|
const description = isDelete
|
||||||
|
? `Are you sure you want to delete ${fullName}? This action cannot be undone.`
|
||||||
|
: `Are you sure you want to deactivate ${fullName}? They will not be able to log in until reactivated.`;
|
||||||
|
const action = isDelete ? handleDelete : handleDeactivate;
|
||||||
|
const actionLabel = isDelete ? 'Delete' : 'Deactivate';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={!!confirmAction} onOpenChange={() => setConfirmAction(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={action}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
aria-label={`Actions for ${fullName}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleEdit}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{user.is_active ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setConfirmAction('deactivate')}
|
||||||
|
disabled={isCurrentUser}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
Deactivate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={handleActivate}>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Activate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setConfirmAction('delete')}
|
||||||
|
disabled={isCurrentUser}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
Delete User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{renderConfirmDialog()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
frontend/src/components/admin/users/UserFormDialog.tsx
Normal file
364
frontend/src/components/admin/users/UserFormDialog.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* UserFormDialog Component
|
||||||
|
* Dialog for creating and editing users with form validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Alert } from '@/components/ui/alert';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
useCreateUser,
|
||||||
|
useUpdateUser,
|
||||||
|
type User,
|
||||||
|
} from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const userFormSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Email is required')
|
||||||
|
.email('Please enter a valid email address'),
|
||||||
|
first_name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'First name is required')
|
||||||
|
.min(2, 'First name must be at least 2 characters')
|
||||||
|
.max(50, 'First name must not exceed 50 characters'),
|
||||||
|
last_name: z
|
||||||
|
.string()
|
||||||
|
.max(50, 'Last name must not exceed 50 characters')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
|
password: z.string(),
|
||||||
|
is_active: z.boolean(),
|
||||||
|
is_superuser: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type UserFormData = z.infer<typeof userFormSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface UserFormDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
user?: User | null;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
user,
|
||||||
|
mode,
|
||||||
|
}: UserFormDialogProps) {
|
||||||
|
const isEdit = mode === 'edit' && user;
|
||||||
|
const createUser = useCreateUser();
|
||||||
|
const updateUser = useUpdateUser();
|
||||||
|
|
||||||
|
const form = useForm<UserFormData>({
|
||||||
|
resolver: zodResolver(userFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
password: '',
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form when dialog opens/closes or user changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && isEdit) {
|
||||||
|
form.reset({
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name || '',
|
||||||
|
password: '',
|
||||||
|
is_active: user.is_active,
|
||||||
|
is_superuser: user.is_superuser,
|
||||||
|
});
|
||||||
|
} else if (open && !isEdit) {
|
||||||
|
form.reset({
|
||||||
|
email: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
password: '',
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, isEdit, user, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: UserFormData) => {
|
||||||
|
try {
|
||||||
|
// Validate password for create mode
|
||||||
|
if (!isEdit) {
|
||||||
|
if (!data.password || data.password.length === 0) {
|
||||||
|
form.setError('password', { message: 'Password is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.password.length < 8) {
|
||||||
|
form.setError('password', { message: 'Password must be at least 8 characters' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/[0-9]/.test(data.password)) {
|
||||||
|
form.setError('password', { message: 'Password must contain at least one number' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/[A-Z]/.test(data.password)) {
|
||||||
|
form.setError('password', { message: 'Password must contain at least one uppercase letter' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
// Validate password if provided in edit mode
|
||||||
|
if (data.password && data.password.length > 0) {
|
||||||
|
if (data.password.length < 8) {
|
||||||
|
form.setError('password', { message: 'Password must be at least 8 characters' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/[0-9]/.test(data.password)) {
|
||||||
|
form.setError('password', { message: 'Password must contain at least one number' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/[A-Z]/.test(data.password)) {
|
||||||
|
form.setError('password', { message: 'Password must contain at least one uppercase letter' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare update data (exclude password if empty)
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
email: data.email,
|
||||||
|
first_name: data.first_name,
|
||||||
|
last_name: data.last_name || null,
|
||||||
|
is_active: data.is_active,
|
||||||
|
is_superuser: data.is_superuser,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include password if provided
|
||||||
|
if (data.password && data.password.length > 0) {
|
||||||
|
updateData.password = data.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser.mutateAsync({
|
||||||
|
userId: user.id,
|
||||||
|
userData: updateData as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`User ${data.first_name} ${data.last_name || ''} updated successfully`);
|
||||||
|
onOpenChange(false);
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
await createUser.mutateAsync({
|
||||||
|
email: data.email,
|
||||||
|
first_name: data.first_name,
|
||||||
|
last_name: data.last_name || undefined,
|
||||||
|
password: data.password,
|
||||||
|
is_active: data.is_active,
|
||||||
|
is_superuser: data.is_superuser,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
toast.success(`User ${data.first_name} ${data.last_name || ''} created successfully`);
|
||||||
|
onOpenChange(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Operation failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const isActive = watch('is_active');
|
||||||
|
const isSuperuser = watch('is_superuser');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Edit User' : 'Create New User'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? 'Update user information and permissions'
|
||||||
|
: 'Add a new user to the system with specified permissions'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
{...register('email')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
aria-invalid={errors.email ? 'true' : 'false'}
|
||||||
|
className={errors.email ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p id="email-error" className="text-sm text-destructive">
|
||||||
|
{errors.email.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* First Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="first_name">First Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="first_name"
|
||||||
|
{...register('first_name')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
aria-invalid={errors.first_name ? 'true' : 'false'}
|
||||||
|
className={errors.first_name ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.first_name && (
|
||||||
|
<p id="first-name-error" className="text-sm text-destructive">
|
||||||
|
{errors.first_name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="last_name">Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="last_name"
|
||||||
|
{...register('last_name')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
aria-invalid={errors.last_name ? 'true' : 'false'}
|
||||||
|
className={errors.last_name ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.last_name && (
|
||||||
|
<p id="last-name-error" className="text-sm text-destructive">
|
||||||
|
{errors.last_name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
Password {!isEdit && '*'} {isEdit && '(leave blank to keep current)'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
{...register('password')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
aria-invalid={errors.password ? 'true' : 'false'}
|
||||||
|
className={errors.password ? 'border-destructive' : ''}
|
||||||
|
placeholder={isEdit ? 'Leave blank to keep current password' : ''}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p id="password-error" className="text-sm text-destructive">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isEdit && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Must be at least 8 characters with 1 number and 1 uppercase letter
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkboxes */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="is_active"
|
||||||
|
checked={isActive}
|
||||||
|
onCheckedChange={(checked) => setValue('is_active', checked as boolean)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="is_active"
|
||||||
|
className="text-sm font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Active (user can log in)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="is_superuser"
|
||||||
|
checked={isSuperuser}
|
||||||
|
onCheckedChange={(checked) => setValue('is_superuser', checked as boolean)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="is_superuser"
|
||||||
|
className="text-sm font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Superuser (admin privileges)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Error Display */}
|
||||||
|
{(createUser.isError || updateUser.isError) && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
{createUser.isError && createUser.error instanceof Error
|
||||||
|
? createUser.error.message
|
||||||
|
: updateUser.error instanceof Error
|
||||||
|
? updateUser.error.message
|
||||||
|
: 'An error occurred'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? isEdit
|
||||||
|
? 'Updating...'
|
||||||
|
: 'Creating...'
|
||||||
|
: isEdit
|
||||||
|
? 'Update User'
|
||||||
|
: 'Create User'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
frontend/src/components/admin/users/UserListTable.tsx
Normal file
300
frontend/src/components/admin/users/UserListTable.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* UserListTable Component
|
||||||
|
* Displays paginated list of users with search, filters, sorting, and bulk selection
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { UserActionMenu } from './UserActionMenu';
|
||||||
|
import type { User, PaginationMeta } from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
|
interface UserListTableProps {
|
||||||
|
users: User[];
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
isLoading: boolean;
|
||||||
|
selectedUsers: string[];
|
||||||
|
onSelectUser: (userId: string) => void;
|
||||||
|
onSelectAll: (selected: boolean) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onSearch: (search: string) => void;
|
||||||
|
onFilterActive: (filter: string | null) => void;
|
||||||
|
onFilterSuperuser: (filter: string | null) => void;
|
||||||
|
onEditUser?: (user: User) => void;
|
||||||
|
currentUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserListTable({
|
||||||
|
users,
|
||||||
|
pagination,
|
||||||
|
isLoading,
|
||||||
|
selectedUsers,
|
||||||
|
onSelectUser,
|
||||||
|
onSelectAll,
|
||||||
|
onPageChange,
|
||||||
|
onSearch,
|
||||||
|
onFilterActive,
|
||||||
|
onFilterSuperuser,
|
||||||
|
onEditUser,
|
||||||
|
currentUserId,
|
||||||
|
}: UserListTableProps) {
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
onSearch(value);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
},
|
||||||
|
[onSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected =
|
||||||
|
users.length > 0 && users.every((user) => selectedUsers.includes(user.id));
|
||||||
|
const someSelected = users.some((user) => selectedUsers.includes(user.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<Select onValueChange={onFilterActive}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="All Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="true">Active</SelectItem>
|
||||||
|
<SelectItem value="false">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select onValueChange={onFilterSuperuser}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="All Users" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Users</SelectItem>
|
||||||
|
<SelectItem value="true">Superusers</SelectItem>
|
||||||
|
<SelectItem value="false">Regular</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={onSelectAll}
|
||||||
|
aria-label="Select all users"
|
||||||
|
disabled={isLoading || users.length === 0}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead className="text-center">Status</TableHead>
|
||||||
|
<TableHead className="text-center">Superuser</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[70px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
// Loading skeleton
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[200px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-5 w-[60px] mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-4 mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
// Empty state
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
No users found. Try adjusting your filters.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
// User rows
|
||||||
|
users.map((user) => {
|
||||||
|
const isCurrentUser = currentUserId === user.id;
|
||||||
|
const fullName = user.last_name
|
||||||
|
? `${user.first_name} ${user.last_name}`
|
||||||
|
: user.first_name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedUsers.includes(user.id)}
|
||||||
|
onCheckedChange={() => onSelectUser(user.id)}
|
||||||
|
aria-label={`Select ${fullName}`}
|
||||||
|
disabled={isCurrentUser}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{fullName}
|
||||||
|
{isCurrentUser && (
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
You
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge
|
||||||
|
variant={user.is_active ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
|
{user.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{user.is_superuser ? (
|
||||||
|
<Check
|
||||||
|
className="h-4 w-4 mx-auto text-green-600"
|
||||||
|
aria-label="Yes"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<X
|
||||||
|
className="h-4 w-4 mx-auto text-muted-foreground"
|
||||||
|
aria-label="No"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{format(new Date(user.created_at), 'MMM d, yyyy')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<UserActionMenu
|
||||||
|
user={user}
|
||||||
|
isCurrentUser={isCurrentUser}
|
||||||
|
onEdit={onEditUser}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!isLoading && users.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
|
||||||
|
{Math.min(
|
||||||
|
pagination.page * pagination.page_size,
|
||||||
|
pagination.total
|
||||||
|
)}{' '}
|
||||||
|
of {pagination.total} users
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(pagination.page - 1)}
|
||||||
|
disabled={!pagination.has_prev}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: pagination.total_pages }, (_, i) => i + 1)
|
||||||
|
.filter(
|
||||||
|
(page) =>
|
||||||
|
page === 1 ||
|
||||||
|
page === pagination.total_pages ||
|
||||||
|
Math.abs(page - pagination.page) <= 1
|
||||||
|
)
|
||||||
|
.map((page, idx, arr) => {
|
||||||
|
const prevPage = arr[idx - 1];
|
||||||
|
const showEllipsis = prevPage && page - prevPage > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={page} className="flex items-center">
|
||||||
|
{showEllipsis && (
|
||||||
|
<span className="px-2 text-muted-foreground">...</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
page === pagination.page ? 'default' : 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className="w-9"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(pagination.page + 1)}
|
||||||
|
disabled={!pagination.has_next}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
frontend/src/components/admin/users/UserManagementContent.tsx
Normal file
170
frontend/src/components/admin/users/UserManagementContent.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* UserManagementContent Component
|
||||||
|
* Client-side content for the user management page
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import { useAdminUsers, type User, type PaginationMeta } from '@/lib/api/hooks/useAdmin';
|
||||||
|
import { UserListTable } from './UserListTable';
|
||||||
|
import { UserFormDialog } from './UserFormDialog';
|
||||||
|
import { BulkActionToolbar } from './BulkActionToolbar';
|
||||||
|
|
||||||
|
export function UserManagementContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
// URL state
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const searchQuery = searchParams.get('search') || '';
|
||||||
|
const filterActive = searchParams.get('active') || null;
|
||||||
|
const filterSuperuser = searchParams.get('superuser') || null;
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
// Fetch users with query params
|
||||||
|
const { data, isLoading } = useAdminUsers(page, 20);
|
||||||
|
|
||||||
|
const users: User[] = data?.data || [];
|
||||||
|
const pagination: PaginationMeta = data?.pagination || {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
total_pages: 1,
|
||||||
|
has_next: false,
|
||||||
|
has_prev: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL update helper
|
||||||
|
const updateURL = useCallback(
|
||||||
|
(params: Record<string, string | number | null>) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value === null || value === '' || value === 'all') {
|
||||||
|
newParams.delete(key);
|
||||||
|
} else {
|
||||||
|
newParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`?${newParams.toString()}`);
|
||||||
|
},
|
||||||
|
[searchParams, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleSelectUser = (userId: string) => {
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (selected: boolean) => {
|
||||||
|
if (selected) {
|
||||||
|
const selectableUsers = users
|
||||||
|
.filter((u: any) => u.id !== currentUser?.id)
|
||||||
|
.map((u: any) => u.id);
|
||||||
|
setSelectedUsers(selectableUsers);
|
||||||
|
} else {
|
||||||
|
setSelectedUsers([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
updateURL({ page: newPage });
|
||||||
|
setSelectedUsers([]); // Clear selection on page change
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (search: string) => {
|
||||||
|
updateURL({ search, page: 1 }); // Reset to page 1 on search
|
||||||
|
setSelectedUsers([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterActive = (filter: string | null) => {
|
||||||
|
updateURL({ active: filter === 'all' ? null : filter, page: 1 });
|
||||||
|
setSelectedUsers([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterSuperuser = (filter: string | null) => {
|
||||||
|
updateURL({ superuser: filter === 'all' ? null : filter, page: 1 });
|
||||||
|
setSelectedUsers([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUser = () => {
|
||||||
|
setDialogMode('create');
|
||||||
|
setEditingUser(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUser = (user: User) => {
|
||||||
|
setDialogMode('edit');
|
||||||
|
setEditingUser(user);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
setSelectedUsers([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with Create Button */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">All Users</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage user accounts and permissions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreateUser}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User List Table */}
|
||||||
|
<UserListTable
|
||||||
|
users={users}
|
||||||
|
pagination={pagination}
|
||||||
|
isLoading={isLoading}
|
||||||
|
selectedUsers={selectedUsers}
|
||||||
|
onSelectUser={handleSelectUser}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onFilterActive={handleFilterActive}
|
||||||
|
onFilterSuperuser={handleFilterSuperuser}
|
||||||
|
onEditUser={handleEditUser}
|
||||||
|
currentUserId={currentUser?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Form Dialog */}
|
||||||
|
<UserFormDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
user={editingUser}
|
||||||
|
mode={dialogMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bulk Action Toolbar */}
|
||||||
|
<BulkActionToolbar
|
||||||
|
selectedCount={selectedUsers.length}
|
||||||
|
onClearSelection={handleClearSelection}
|
||||||
|
selectedUserIds={selectedUsers}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/components/ui/alert-dialog.tsx
Normal file
157
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils/index"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils/index"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
|||||||
@@ -11,8 +11,28 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminListUsers, adminListOrganizations } from '@/lib/api/client';
|
import {
|
||||||
|
adminListUsers,
|
||||||
|
adminListOrganizations,
|
||||||
|
adminCreateUser,
|
||||||
|
adminGetUser,
|
||||||
|
adminUpdateUser,
|
||||||
|
adminDeleteUser,
|
||||||
|
adminActivateUser,
|
||||||
|
adminDeactivateUser,
|
||||||
|
adminBulkUserAction,
|
||||||
|
type UserCreate,
|
||||||
|
type UserUpdate,
|
||||||
|
} from '@/lib/api/client';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants for admin hooks
|
||||||
|
*/
|
||||||
|
const STATS_FETCH_LIMIT = 100; // Maximum allowed by backend pagination (use pagination.total for actual count)
|
||||||
|
const STATS_REFETCH_INTERVAL = 30000; // 30 seconds - refetch interval for near real-time stats
|
||||||
|
const DEFAULT_PAGE_LIMIT = 50; // Default number of records per page for paginated lists
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin Stats interface
|
* Admin Stats interface
|
||||||
@@ -31,6 +51,8 @@ export interface AdminStats {
|
|||||||
* @returns Admin statistics including user and organization counts
|
* @returns Admin statistics including user and organization counts
|
||||||
*/
|
*/
|
||||||
export function useAdminStats() {
|
export function useAdminStats() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'stats'],
|
queryKey: ['admin', 'stats'],
|
||||||
queryFn: async (): Promise<AdminStats> => {
|
queryFn: async (): Promise<AdminStats> => {
|
||||||
@@ -39,7 +61,7 @@ export function useAdminStats() {
|
|||||||
const usersResponse = await adminListUsers({
|
const usersResponse = await adminListUsers({
|
||||||
query: {
|
query: {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10000, // High limit to get all users for stats
|
limit: STATS_FETCH_LIMIT,
|
||||||
},
|
},
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
});
|
});
|
||||||
@@ -58,7 +80,7 @@ export function useAdminStats() {
|
|||||||
const orgsResponse = await adminListOrganizations({
|
const orgsResponse = await adminListOrganizations({
|
||||||
query: {
|
query: {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10000, // High limit to get all orgs for stats
|
limit: STATS_FETCH_LIMIT,
|
||||||
},
|
},
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
});
|
});
|
||||||
@@ -93,12 +115,47 @@ export function useAdminStats() {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Refetch every 30 seconds for near real-time stats
|
// Refetch every 30 seconds for near real-time stats
|
||||||
refetchInterval: 30000,
|
refetchInterval: STATS_REFETCH_INTERVAL,
|
||||||
// Keep previous data while refetching to avoid UI flicker
|
// Keep previous data while refetching to avoid UI flicker
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
|
// Only fetch if user is a superuser (frontend guard)
|
||||||
|
enabled: user?.is_superuser === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination metadata structure
|
||||||
|
*/
|
||||||
|
export interface PaginationMeta {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_pages: number;
|
||||||
|
has_next: boolean;
|
||||||
|
has_prev: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User interface matching backend UserResponse
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
is_superuser: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated user list response
|
||||||
|
*/
|
||||||
|
export interface PaginatedUserResponse {
|
||||||
|
data: User[];
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch paginated list of all users (for admin)
|
* Hook to fetch paginated list of all users (for admin)
|
||||||
*
|
*
|
||||||
@@ -106,10 +163,12 @@ export function useAdminStats() {
|
|||||||
* @param limit - Number of records per page
|
* @param limit - Number of records per page
|
||||||
* @returns Paginated list of users
|
* @returns Paginated list of users
|
||||||
*/
|
*/
|
||||||
export function useAdminUsers(page = 1, limit = 50) {
|
export function useAdminUsers(page = 1, limit = DEFAULT_PAGE_LIMIT) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'users', page, limit],
|
queryKey: ['admin', 'users', page, limit],
|
||||||
queryFn: async () => {
|
queryFn: async (): Promise<PaginatedUserResponse> => {
|
||||||
const response = await adminListUsers({
|
const response = await adminListUsers({
|
||||||
query: { page, limit },
|
query: { page, limit },
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
@@ -120,8 +179,10 @@ export function useAdminUsers(page = 1, limit = 50) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Type assertion: if no error, response has data
|
// Type assertion: if no error, response has data
|
||||||
return (response as { data: unknown }).data;
|
return (response as { data: PaginatedUserResponse }).data;
|
||||||
},
|
},
|
||||||
|
// Only fetch if user is a superuser (frontend guard)
|
||||||
|
enabled: user?.is_superuser === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +193,9 @@ export function useAdminUsers(page = 1, limit = 50) {
|
|||||||
* @param limit - Number of records per page
|
* @param limit - Number of records per page
|
||||||
* @returns Paginated list of organizations
|
* @returns Paginated list of organizations
|
||||||
*/
|
*/
|
||||||
export function useAdminOrganizations(page = 1, limit = 50) {
|
export function useAdminOrganizations(page = 1, limit = DEFAULT_PAGE_LIMIT) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'organizations', page, limit],
|
queryKey: ['admin', 'organizations', page, limit],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -148,5 +211,194 @@ export function useAdminOrganizations(page = 1, limit = 50) {
|
|||||||
// Type assertion: if no error, response has data
|
// Type assertion: if no error, response has data
|
||||||
return (response as { data: unknown }).data;
|
return (response as { data: unknown }).data;
|
||||||
},
|
},
|
||||||
|
// Only fetch if user is a superuser (frontend guard)
|
||||||
|
enabled: user?.is_superuser === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create a new user (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for creating users
|
||||||
|
*/
|
||||||
|
export function useCreateUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userData: UserCreate) => {
|
||||||
|
const response = await adminCreateUser({
|
||||||
|
body: userData,
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to create user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate user queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to update an existing user (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for updating users
|
||||||
|
*/
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
userId,
|
||||||
|
userData,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
userData: UserUpdate;
|
||||||
|
}) => {
|
||||||
|
const response = await adminUpdateUser({
|
||||||
|
path: { user_id: userId },
|
||||||
|
body: userData,
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to update user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate user queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to delete a user (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for deleting users
|
||||||
|
*/
|
||||||
|
export function useDeleteUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) => {
|
||||||
|
const response = await adminDeleteUser({
|
||||||
|
path: { user_id: userId },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to delete user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate user queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to activate a user (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for activating users
|
||||||
|
*/
|
||||||
|
export function useActivateUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) => {
|
||||||
|
const response = await adminActivateUser({
|
||||||
|
path: { user_id: userId },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to activate user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate user queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to deactivate a user (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for deactivating users
|
||||||
|
*/
|
||||||
|
export function useDeactivateUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) => {
|
||||||
|
const response = await adminDeactivateUser({
|
||||||
|
path: { user_id: userId },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to deactivate user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate user queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to perform bulk actions on users (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for bulk user actions
|
||||||
|
*/
|
||||||
|
export function useBulkUserAction() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
action,
|
||||||
|
userIds,
|
||||||
|
}: {
|
||||||
|
action: 'activate' | 'deactivate' | 'delete';
|
||||||
|
userIds: string[];
|
||||||
|
}) => {
|
||||||
|
const response = await adminBulkUserAction({
|
||||||
|
body: { action, user_ids: userIds },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to perform bulk action');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate user queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
169
frontend/tests/app/admin/layout.test.tsx
Normal file
169
frontend/tests/app/admin/layout.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Admin Layout
|
||||||
|
* Verifies layout rendering, auth guard, and accessibility features
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import AdminLayout from '@/app/admin/layout';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('@/lib/auth/AuthContext');
|
||||||
|
jest.mock('@/components/layout/Header', () => ({
|
||||||
|
Header: () => <header data-testid="header">Header</header>,
|
||||||
|
}));
|
||||||
|
jest.mock('@/components/layout/Footer', () => ({
|
||||||
|
Footer: () => <footer data-testid="footer">Footer</footer>,
|
||||||
|
}));
|
||||||
|
jest.mock('@/components/admin/AdminSidebar', () => ({
|
||||||
|
AdminSidebar: () => <aside data-testid="sidebar">Sidebar</aside>,
|
||||||
|
}));
|
||||||
|
jest.mock('@/components/admin/Breadcrumbs', () => ({
|
||||||
|
Breadcrumbs: () => <div data-testid="breadcrumbs">Breadcrumbs</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
prefetch: jest.fn(),
|
||||||
|
}),
|
||||||
|
usePathname: () => '/admin',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
|
||||||
|
|
||||||
|
describe('AdminLayout', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
it('renders layout with all components for superuser', () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminLayout>
|
||||||
|
<div>Test Content</div>
|
||||||
|
</AdminLayout>,
|
||||||
|
{ wrapper }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('header')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('footer')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders skip link with correct attributes', () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminLayout>
|
||||||
|
<div>Test Content</div>
|
||||||
|
</AdminLayout>,
|
||||||
|
{ wrapper }
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipLink = screen.getByText('Skip to main content');
|
||||||
|
expect(skipLink).toBeInTheDocument();
|
||||||
|
expect(skipLink).toHaveAttribute('href', '#main-content');
|
||||||
|
expect(skipLink).toHaveClass('sr-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders main element with id', () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<AdminLayout>
|
||||||
|
<div>Test Content</div>
|
||||||
|
</AdminLayout>,
|
||||||
|
{ wrapper }
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainElement = container.querySelector('#main-content');
|
||||||
|
expect(mainElement).toBeInTheDocument();
|
||||||
|
expect(mainElement?.tagName).toBe('MAIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children inside main content area', () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminLayout>
|
||||||
|
<div data-testid="child-content">Child Content</div>
|
||||||
|
</AdminLayout>,
|
||||||
|
{ wrapper }
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainElement = screen.getByTestId('child-content').closest('main');
|
||||||
|
expect(mainElement).toHaveAttribute('id', 'main-content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct layout structure classes', () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<AdminLayout>
|
||||||
|
<div>Test Content</div>
|
||||||
|
</AdminLayout>,
|
||||||
|
{ wrapper }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check root container has min-height class
|
||||||
|
const rootDiv = container.querySelector('.min-h-screen');
|
||||||
|
expect(rootDiv).toBeInTheDocument();
|
||||||
|
expect(rootDiv).toHaveClass('flex', 'flex-col');
|
||||||
|
|
||||||
|
// Check main content area has flex and overflow classes
|
||||||
|
const mainElement = container.querySelector('#main-content');
|
||||||
|
expect(mainElement).toHaveClass('flex-1', 'overflow-y-auto');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,35 +4,44 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import AdminPage from '@/app/admin/page';
|
import AdminPage from '@/app/admin/page';
|
||||||
|
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
// Helper function to render with QueryClientProvider
|
// Mock the useAdminStats hook
|
||||||
function renderWithQueryClient(component: React.ReactElement) {
|
jest.mock('@/lib/api/hooks/useAdmin');
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
const mockUseAdminStats = useAdminStats as jest.MockedFunction<typeof useAdminStats>;
|
||||||
queries: {
|
|
||||||
retry: false,
|
// Helper function to render with default mocked stats
|
||||||
},
|
function renderWithMockedStats() {
|
||||||
|
mockUseAdminStats.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
totalUsers: 100,
|
||||||
|
activeUsers: 80,
|
||||||
|
totalOrganizations: 20,
|
||||||
|
totalSessions: 30,
|
||||||
},
|
},
|
||||||
});
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
return render(
|
return render(<AdminPage />);
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{component}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AdminPage', () => {
|
describe('AdminPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders admin dashboard title', () => {
|
it('renders admin dashboard title', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
|
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders description text', () => {
|
it('renders description text', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Manage users, organizations, and system settings')
|
screen.getByText('Manage users, organizations, and system settings')
|
||||||
@@ -40,13 +49,13 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders quick actions section', () => {
|
it('renders quick actions section', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
|
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders user management card', () => {
|
it('renders user management card', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(screen.getByText('User Management')).toBeInTheDocument();
|
expect(screen.getByText('User Management')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -55,7 +64,7 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders organizations card', () => {
|
it('renders organizations card', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
// Check for the quick actions card (not the stat card)
|
// Check for the quick actions card (not the stat card)
|
||||||
expect(
|
expect(
|
||||||
@@ -64,7 +73,7 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders system settings card', () => {
|
it('renders system settings card', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(screen.getByText('System Settings')).toBeInTheDocument();
|
expect(screen.getByText('System Settings')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -73,7 +82,7 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders quick actions in grid layout', () => {
|
it('renders quick actions in grid layout', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
// Check for Quick Actions heading which is above the grid
|
// Check for Quick Actions heading which is above the grid
|
||||||
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
|
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
|
||||||
@@ -84,7 +93,7 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders with proper container structure', () => {
|
it('renders with proper container structure', () => {
|
||||||
const { container } = renderWithQueryClient(<AdminPage />);
|
const { container } = renderWithMockedStats();
|
||||||
|
|
||||||
const containerDiv = container.querySelector('.container');
|
const containerDiv = container.querySelector('.container');
|
||||||
expect(containerDiv).toBeInTheDocument();
|
expect(containerDiv).toBeInTheDocument();
|
||||||
|
|||||||
66
frontend/tests/app/forbidden/page.test.tsx
Normal file
66
frontend/tests/app/forbidden/page.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Tests for 403 Forbidden Page
|
||||||
|
* Verifies rendering of access forbidden message and navigation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import ForbiddenPage from '@/app/forbidden/page';
|
||||||
|
|
||||||
|
describe('ForbiddenPage', () => {
|
||||||
|
it('renders page heading', () => {
|
||||||
|
render(<ForbiddenPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: /403 - Access Forbidden/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders permission denied message', () => {
|
||||||
|
render(<ForbiddenPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/You don't have permission to access this resource/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders admin privileges message', () => {
|
||||||
|
render(<ForbiddenPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/This page requires administrator privileges/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders link to dashboard', () => {
|
||||||
|
render(<ForbiddenPage />);
|
||||||
|
|
||||||
|
const dashboardLink = screen.getByRole('link', {
|
||||||
|
name: /Go to Dashboard/i,
|
||||||
|
});
|
||||||
|
expect(dashboardLink).toBeInTheDocument();
|
||||||
|
expect(dashboardLink).toHaveAttribute('href', '/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders link to home', () => {
|
||||||
|
render(<ForbiddenPage />);
|
||||||
|
|
||||||
|
const homeLink = screen.getByRole('link', { name: /Go to Home/i });
|
||||||
|
expect(homeLink).toBeInTheDocument();
|
||||||
|
expect(homeLink).toHaveAttribute('href', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders shield alert icon with aria-hidden', () => {
|
||||||
|
const { container } = render(<ForbiddenPage />);
|
||||||
|
|
||||||
|
const icon = container.querySelector('[aria-hidden="true"]');
|
||||||
|
expect(icon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with proper container structure', () => {
|
||||||
|
const { container } = render(<ForbiddenPage />);
|
||||||
|
|
||||||
|
const containerDiv = container.querySelector('.container');
|
||||||
|
expect(containerDiv).toBeInTheDocument();
|
||||||
|
expect(containerDiv).toHaveClass('mx-auto', 'px-6', 'py-16');
|
||||||
|
});
|
||||||
|
});
|
||||||
157
frontend/tests/components/admin/DashboardStats.test.tsx
Normal file
157
frontend/tests/components/admin/DashboardStats.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DashboardStats Component
|
||||||
|
* Verifies dashboard statistics display and error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { DashboardStats } from '@/components/admin/DashboardStats';
|
||||||
|
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
|
// Mock the useAdminStats hook
|
||||||
|
jest.mock('@/lib/api/hooks/useAdmin');
|
||||||
|
|
||||||
|
const mockUseAdminStats = useAdminStats as jest.MockedFunction<typeof useAdminStats>;
|
||||||
|
|
||||||
|
describe('DashboardStats', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all stat cards with data', () => {
|
||||||
|
mockUseAdminStats.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
totalUsers: 150,
|
||||||
|
activeUsers: 120,
|
||||||
|
totalOrganizations: 25,
|
||||||
|
totalSessions: 45,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<DashboardStats />);
|
||||||
|
|
||||||
|
// Check stat cards are rendered
|
||||||
|
expect(screen.getByText('Total Users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('150')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('All registered users')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText('Active Users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('120')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Users with active status')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText('Organizations')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('25')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Total organizations')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText('Active Sessions')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('45')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Current active sessions')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state', () => {
|
||||||
|
mockUseAdminStats.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<DashboardStats />);
|
||||||
|
|
||||||
|
// StatCard component should render loading state
|
||||||
|
expect(screen.getByText('Total Users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Active Users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Organizations')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Active Sessions')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error state', () => {
|
||||||
|
mockUseAdminStats.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: true,
|
||||||
|
error: new Error('Network error occurred'),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<DashboardStats />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Failed to load dashboard statistics/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Network error occurred/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error state with default message when error message is missing', () => {
|
||||||
|
mockUseAdminStats.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: true,
|
||||||
|
error: {} as any,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<DashboardStats />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Failed to load dashboard statistics/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Unknown error/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with zero values', () => {
|
||||||
|
mockUseAdminStats.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
totalUsers: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
totalOrganizations: 0,
|
||||||
|
totalSessions: 0,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<DashboardStats />);
|
||||||
|
|
||||||
|
// Check all zeros are displayed
|
||||||
|
const zeroValues = screen.getAllByText('0');
|
||||||
|
expect(zeroValues.length).toBe(4); // 4 stat cards with 0 value
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with dashboard-stats test id', () => {
|
||||||
|
mockUseAdminStats.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
totalUsers: 100,
|
||||||
|
activeUsers: 80,
|
||||||
|
totalOrganizations: 20,
|
||||||
|
totalSessions: 30,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = render(<DashboardStats />);
|
||||||
|
|
||||||
|
const dashboardStats = container.querySelector('[data-testid="dashboard-stats"]');
|
||||||
|
expect(dashboardStats).toBeInTheDocument();
|
||||||
|
expect(dashboardStats).toHaveClass('grid', 'gap-4', 'md:grid-cols-2', 'lg:grid-cols-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icons with aria-hidden', () => {
|
||||||
|
mockUseAdminStats.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
totalUsers: 100,
|
||||||
|
activeUsers: 80,
|
||||||
|
totalOrganizations: 20,
|
||||||
|
totalSessions: 30,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = render(<DashboardStats />);
|
||||||
|
|
||||||
|
// Check that icons have aria-hidden attribute
|
||||||
|
const icons = container.querySelectorAll('[aria-hidden="true"]');
|
||||||
|
expect(icons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
598
frontend/tests/lib/api/hooks/useAdmin.test.tsx
Normal file
598
frontend/tests/lib/api/hooks/useAdmin.test.tsx
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
/**
|
||||||
|
* Tests for useAdmin hooks
|
||||||
|
* Verifies admin statistics and list fetching functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
useAdminStats,
|
||||||
|
useAdminUsers,
|
||||||
|
useAdminOrganizations,
|
||||||
|
useCreateUser,
|
||||||
|
useUpdateUser,
|
||||||
|
useDeleteUser,
|
||||||
|
useActivateUser,
|
||||||
|
useDeactivateUser,
|
||||||
|
useBulkUserAction,
|
||||||
|
} from '@/lib/api/hooks/useAdmin';
|
||||||
|
import {
|
||||||
|
adminListUsers,
|
||||||
|
adminListOrganizations,
|
||||||
|
adminCreateUser,
|
||||||
|
adminUpdateUser,
|
||||||
|
adminDeleteUser,
|
||||||
|
adminActivateUser,
|
||||||
|
adminDeactivateUser,
|
||||||
|
adminBulkUserAction,
|
||||||
|
} from '@/lib/api/client';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('@/lib/api/client');
|
||||||
|
jest.mock('@/lib/auth/AuthContext');
|
||||||
|
|
||||||
|
const mockAdminListUsers = adminListUsers as jest.MockedFunction<typeof adminListUsers>;
|
||||||
|
const mockAdminListOrganizations = adminListOrganizations as jest.MockedFunction<typeof adminListOrganizations>;
|
||||||
|
const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
|
||||||
|
|
||||||
|
describe('useAdmin hooks', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('useAdminStats', () => {
|
||||||
|
const mockUsersData = {
|
||||||
|
data: {
|
||||||
|
data: [
|
||||||
|
{ is_active: true },
|
||||||
|
{ is_active: true },
|
||||||
|
{ is_active: false },
|
||||||
|
],
|
||||||
|
pagination: { total: 3, page: 1, limit: 10000 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockOrgsData = {
|
||||||
|
data: {
|
||||||
|
pagination: { total: 5 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('fetches and calculates stats when user is superuser', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListUsers.mockResolvedValue(mockUsersData as any);
|
||||||
|
mockAdminListOrganizations.mockResolvedValue(mockOrgsData as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminStats(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual({
|
||||||
|
totalUsers: 3,
|
||||||
|
activeUsers: 2,
|
||||||
|
totalOrganizations: 5,
|
||||||
|
totalSessions: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAdminListUsers).toHaveBeenCalledWith({
|
||||||
|
query: { page: 1, limit: 100 },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
|
||||||
|
query: { page: 1, limit: 100 },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch when user is not superuser', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: false } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminStats(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
expect(result.current.data).toBeUndefined();
|
||||||
|
expect(mockAdminListUsers).not.toHaveBeenCalled();
|
||||||
|
expect(mockAdminListOrganizations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch when user is null', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminStats(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
expect(result.current.data).toBeUndefined();
|
||||||
|
expect(mockAdminListUsers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles users API error', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListUsers.mockResolvedValue({ error: 'Users fetch failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminStats(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
expect(result.current.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles organizations API error', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListUsers.mockResolvedValue(mockUsersData as any);
|
||||||
|
mockAdminListOrganizations.mockResolvedValue({ error: 'Orgs fetch failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminStats(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
expect(result.current.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAdminUsers', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
data: [{ id: '1' }, { id: '2' }],
|
||||||
|
pagination: { total: 2, page: 1, limit: 50 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('fetches users when user is superuser', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListUsers.mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminUsers(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockResponse.data);
|
||||||
|
expect(mockAdminListUsers).toHaveBeenCalledWith({
|
||||||
|
query: { page: 1, limit: 50 },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom page and limit parameters', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListUsers.mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
renderHook(() => useAdminUsers(2, 100), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAdminListUsers).toHaveBeenCalledWith({
|
||||||
|
query: { page: 2, limit: 100 },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch when user is not superuser', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: false } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminUsers(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
expect(result.current.data).toBeUndefined();
|
||||||
|
expect(mockAdminListUsers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API error', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListUsers.mockResolvedValue({ error: 'Fetch failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminUsers(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
expect(result.current.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAdminOrganizations', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
data: [{ id: '1' }, { id: '2' }],
|
||||||
|
pagination: { total: 2, page: 1, limit: 50 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('fetches organizations when user is superuser', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListOrganizations.mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockResponse.data);
|
||||||
|
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
|
||||||
|
query: { page: 1, limit: 50 },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom page and limit parameters', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListOrganizations.mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
renderHook(() => useAdminOrganizations(3, 25), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
|
||||||
|
query: { page: 3, limit: 25 },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch when user is not superuser', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: false } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
expect(result.current.data).toBeUndefined();
|
||||||
|
expect(mockAdminListOrganizations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API error', async () => {
|
||||||
|
mockUseAuth.mockReturnValue({
|
||||||
|
user: { is_superuser: true } as any,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminListOrganizations.mockResolvedValue({ error: 'Fetch failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
expect(result.current.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCreateUser', () => {
|
||||||
|
it('creates a user successfully', async () => {
|
||||||
|
const mockCreateUser = adminCreateUser as jest.MockedFunction<typeof adminCreateUser>;
|
||||||
|
mockCreateUser.mockResolvedValue({
|
||||||
|
data: { id: '1', email: 'newuser@example.com', first_name: 'New', last_name: 'User', is_active: true, is_superuser: false, created_at: '2025-01-01T00:00:00Z' },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCreateUser(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
email: 'newuser@example.com',
|
||||||
|
first_name: 'New',
|
||||||
|
last_name: 'User',
|
||||||
|
password: 'Password123',
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreateUser).toHaveBeenCalledWith({
|
||||||
|
body: {
|
||||||
|
email: 'newuser@example.com',
|
||||||
|
first_name: 'New',
|
||||||
|
last_name: 'User',
|
||||||
|
password: 'Password123',
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
},
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles create error', async () => {
|
||||||
|
const mockCreateUser = adminCreateUser as jest.MockedFunction<typeof adminCreateUser>;
|
||||||
|
mockCreateUser.mockResolvedValue({ error: 'Create failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCreateUser(), { wrapper });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
result.current.mutateAsync({
|
||||||
|
email: 'test@example.com',
|
||||||
|
first_name: 'Test',
|
||||||
|
password: 'Password123',
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Failed to create user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useUpdateUser', () => {
|
||||||
|
it('updates a user successfully', async () => {
|
||||||
|
const mockUpdateUser = adminUpdateUser as jest.MockedFunction<typeof adminUpdateUser>;
|
||||||
|
mockUpdateUser.mockResolvedValue({
|
||||||
|
data: { id: '1', email: 'updated@example.com', first_name: 'Updated', last_name: 'User', is_active: true, is_superuser: false, created_at: '2025-01-01T00:00:00Z' },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateUser(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
userId: '1',
|
||||||
|
userData: {
|
||||||
|
email: 'updated@example.com',
|
||||||
|
first_name: 'Updated',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateUser).toHaveBeenCalledWith({
|
||||||
|
path: { user_id: '1' },
|
||||||
|
body: {
|
||||||
|
email: 'updated@example.com',
|
||||||
|
first_name: 'Updated',
|
||||||
|
},
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles update error', async () => {
|
||||||
|
const mockUpdateUser = adminUpdateUser as jest.MockedFunction<typeof adminUpdateUser>;
|
||||||
|
mockUpdateUser.mockResolvedValue({ error: 'Update failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateUser(), { wrapper });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
result.current.mutateAsync({
|
||||||
|
userId: '1',
|
||||||
|
userData: { email: 'test@example.com' },
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Failed to update user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDeleteUser', () => {
|
||||||
|
it('deletes a user successfully', async () => {
|
||||||
|
const mockDeleteUser = adminDeleteUser as jest.MockedFunction<typeof adminDeleteUser>;
|
||||||
|
mockDeleteUser.mockResolvedValue({ data: { success: true } } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeleteUser(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDeleteUser).toHaveBeenCalledWith({
|
||||||
|
path: { user_id: '1' },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles delete error', async () => {
|
||||||
|
const mockDeleteUser = adminDeleteUser as jest.MockedFunction<typeof adminDeleteUser>;
|
||||||
|
mockDeleteUser.mockResolvedValue({ error: 'Delete failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeleteUser(), { wrapper });
|
||||||
|
|
||||||
|
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to delete user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useActivateUser', () => {
|
||||||
|
it('activates a user successfully', async () => {
|
||||||
|
const mockActivateUser = adminActivateUser as jest.MockedFunction<typeof adminActivateUser>;
|
||||||
|
mockActivateUser.mockResolvedValue({ data: { success: true } } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useActivateUser(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockActivateUser).toHaveBeenCalledWith({
|
||||||
|
path: { user_id: '1' },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles activate error', async () => {
|
||||||
|
const mockActivateUser = adminActivateUser as jest.MockedFunction<typeof adminActivateUser>;
|
||||||
|
mockActivateUser.mockResolvedValue({ error: 'Activate failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useActivateUser(), { wrapper });
|
||||||
|
|
||||||
|
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to activate user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDeactivateUser', () => {
|
||||||
|
it('deactivates a user successfully', async () => {
|
||||||
|
const mockDeactivateUser = adminDeactivateUser as jest.MockedFunction<typeof adminDeactivateUser>;
|
||||||
|
mockDeactivateUser.mockResolvedValue({ data: { success: true } } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeactivateUser(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDeactivateUser).toHaveBeenCalledWith({
|
||||||
|
path: { user_id: '1' },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles deactivate error', async () => {
|
||||||
|
const mockDeactivateUser = adminDeactivateUser as jest.MockedFunction<typeof adminDeactivateUser>;
|
||||||
|
mockDeactivateUser.mockResolvedValue({ error: 'Deactivate failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeactivateUser(), { wrapper });
|
||||||
|
|
||||||
|
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to deactivate user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useBulkUserAction', () => {
|
||||||
|
it('performs bulk activate successfully', async () => {
|
||||||
|
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
|
||||||
|
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 2 } } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
action: 'activate',
|
||||||
|
userIds: ['1', '2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockBulkAction).toHaveBeenCalledWith({
|
||||||
|
body: { action: 'activate', user_ids: ['1', '2'] },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('performs bulk deactivate successfully', async () => {
|
||||||
|
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
|
||||||
|
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 3 } } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
action: 'deactivate',
|
||||||
|
userIds: ['1', '2', '3'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockBulkAction).toHaveBeenCalledWith({
|
||||||
|
body: { action: 'deactivate', user_ids: ['1', '2', '3'] },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('performs bulk delete successfully', async () => {
|
||||||
|
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
|
||||||
|
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 1 } } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
action: 'delete',
|
||||||
|
userIds: ['1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockBulkAction).toHaveBeenCalledWith({
|
||||||
|
body: { action: 'delete', user_ids: ['1'] },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles bulk action error', async () => {
|
||||||
|
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
|
||||||
|
mockBulkAction.mockResolvedValue({ error: 'Bulk action failed' } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
result.current.mutateAsync({
|
||||||
|
action: 'activate',
|
||||||
|
userIds: ['1', '2'],
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Failed to perform bulk action');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user