Compare commits
3 Commits
96ae9295d3
...
ff758f5d10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff758f5d10 | ||
|
|
da1f4e365a | ||
|
|
01e0b9ab21 |
@@ -19,6 +19,7 @@ from app.core.database import get_db
|
|||||||
from app.core.exceptions import NotFoundError, DuplicateError, AuthorizationError, ErrorCode
|
from app.core.exceptions import NotFoundError, DuplicateError, AuthorizationError, ErrorCode
|
||||||
from app.crud.organization import organization as organization_crud
|
from app.crud.organization import organization as organization_crud
|
||||||
from app.crud.user import user as user_crud
|
from app.crud.user import user as user_crud
|
||||||
|
from app.crud.session import session as session_crud
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.user_organization import OrganizationRole
|
from app.models.user_organization import OrganizationRole
|
||||||
from app.schemas.common import (
|
from app.schemas.common import (
|
||||||
@@ -35,6 +36,7 @@ from app.schemas.organizations import (
|
|||||||
OrganizationMemberResponse
|
OrganizationMemberResponse
|
||||||
)
|
)
|
||||||
from app.schemas.users import UserResponse, UserCreate, UserUpdate
|
from app.schemas.users import UserResponse, UserCreate, UserUpdate
|
||||||
|
from app.schemas.sessions import AdminSessionResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -784,3 +786,82 @@ async def admin_remove_organization_member(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error removing member from organization (admin): {str(e)}", exc_info=True)
|
logger.error(f"Error removing member from organization (admin): {str(e)}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Session Management Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sessions",
|
||||||
|
response_model=PaginatedResponse[AdminSessionResponse],
|
||||||
|
summary="Admin: List All Sessions",
|
||||||
|
description="""
|
||||||
|
List all sessions across all users (admin only).
|
||||||
|
|
||||||
|
Returns paginated list of sessions with user information.
|
||||||
|
Useful for admin dashboard statistics and session monitoring.
|
||||||
|
""",
|
||||||
|
operation_id="admin_list_sessions"
|
||||||
|
)
|
||||||
|
async def admin_list_sessions(
|
||||||
|
pagination: PaginationParams = Depends(),
|
||||||
|
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||||
|
admin: User = Depends(require_superuser),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> Any:
|
||||||
|
"""List all sessions across all users with filtering and pagination."""
|
||||||
|
try:
|
||||||
|
# Get sessions with user info (eager loaded to prevent N+1)
|
||||||
|
sessions, total = await session_crud.get_all_sessions(
|
||||||
|
db,
|
||||||
|
skip=pagination.offset,
|
||||||
|
limit=pagination.limit,
|
||||||
|
active_only=is_active if is_active is not None else True,
|
||||||
|
with_user=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response objects with user information
|
||||||
|
session_responses = []
|
||||||
|
for session in sessions:
|
||||||
|
# Get user full name
|
||||||
|
user_full_name = None
|
||||||
|
if session.user.first_name or session.user.last_name:
|
||||||
|
parts = []
|
||||||
|
if session.user.first_name:
|
||||||
|
parts.append(session.user.first_name)
|
||||||
|
if session.user.last_name:
|
||||||
|
parts.append(session.user.last_name)
|
||||||
|
user_full_name = " ".join(parts)
|
||||||
|
|
||||||
|
session_response = AdminSessionResponse(
|
||||||
|
id=session.id,
|
||||||
|
user_id=session.user_id,
|
||||||
|
user_email=session.user.email,
|
||||||
|
user_full_name=user_full_name,
|
||||||
|
device_name=session.device_name,
|
||||||
|
device_id=session.device_id,
|
||||||
|
ip_address=session.ip_address,
|
||||||
|
location_city=session.location_city,
|
||||||
|
location_country=session.location_country,
|
||||||
|
last_used_at=session.last_used_at,
|
||||||
|
created_at=session.created_at,
|
||||||
|
expires_at=session.expires_at,
|
||||||
|
is_active=session.is_active
|
||||||
|
)
|
||||||
|
session_responses.append(session_response)
|
||||||
|
|
||||||
|
logger.info(f"Admin {admin.email} listed {len(session_responses)} sessions (total: {total})")
|
||||||
|
|
||||||
|
pagination_meta = create_pagination_meta(
|
||||||
|
total=total,
|
||||||
|
page=pagination.page,
|
||||||
|
limit=pagination.limit,
|
||||||
|
items_count=len(session_responses)
|
||||||
|
)
|
||||||
|
|
||||||
|
return PaginatedResponse(data=session_responses, pagination=pagination_meta)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing sessions (admin): {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -420,6 +420,59 @@ class CRUDSession(CRUDBase[UserSession, SessionCreate, SessionUpdate]):
|
|||||||
logger.error(f"Error counting sessions for user {user_id}: {str(e)}")
|
logger.error(f"Error counting sessions for user {user_id}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def get_all_sessions(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
active_only: bool = True,
|
||||||
|
with_user: bool = True
|
||||||
|
) -> tuple[List[UserSession], int]:
|
||||||
|
"""
|
||||||
|
Get all sessions across all users with pagination (admin only).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
skip: Number of records to skip
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
active_only: If True, return only active sessions
|
||||||
|
with_user: If True, eager load user relationship to prevent N+1
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of UserSession objects, total count)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Build query
|
||||||
|
query = select(UserSession)
|
||||||
|
|
||||||
|
# Add eager loading if requested to prevent N+1 queries
|
||||||
|
if with_user:
|
||||||
|
query = query.options(joinedload(UserSession.user))
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
query = query.where(UserSession.is_active == True)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(func.count(UserSession.id))
|
||||||
|
if active_only:
|
||||||
|
count_query = count_query.where(UserSession.is_active == True)
|
||||||
|
|
||||||
|
count_result = await db.execute(count_query)
|
||||||
|
total = count_result.scalar_one()
|
||||||
|
|
||||||
|
# Apply pagination and ordering
|
||||||
|
query = query.order_by(UserSession.last_used_at.desc()).offset(skip).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
sessions = list(result.scalars().all())
|
||||||
|
|
||||||
|
return sessions, total
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting all sessions: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
# Create singleton instance
|
# Create singleton instance
|
||||||
session = CRUDSession(UserSession)
|
session = CRUDSession(UserSession)
|
||||||
|
|||||||
@@ -110,6 +110,46 @@ class LogoutRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionResponse(SessionBase):
|
||||||
|
"""
|
||||||
|
Schema for session responses in admin panel.
|
||||||
|
|
||||||
|
Includes user information for admin to see who owns each session.
|
||||||
|
"""
|
||||||
|
id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
user_email: str = Field(..., description="Email of the user who owns this session")
|
||||||
|
user_full_name: Optional[str] = Field(None, description="Full name of the user")
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
location_city: Optional[str] = None
|
||||||
|
location_country: Optional[str] = None
|
||||||
|
last_used_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"user_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||||
|
"user_email": "user@example.com",
|
||||||
|
"user_full_name": "John Doe",
|
||||||
|
"device_name": "iPhone 14",
|
||||||
|
"device_id": "device-abc-123",
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"location_city": "San Francisco",
|
||||||
|
"location_country": "United States",
|
||||||
|
"last_used_at": "2025-10-31T12:00:00Z",
|
||||||
|
"created_at": "2025-10-30T09:00:00Z",
|
||||||
|
"expires_at": "2025-11-06T09:00:00Z",
|
||||||
|
"is_active": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeviceInfo(BaseModel):
|
class DeviceInfo(BaseModel):
|
||||||
"""Device information extracted from request."""
|
"""Device information extracted from request."""
|
||||||
device_name: Optional[str] = None
|
device_name: Optional[str] = None
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from fastapi import status
|
|||||||
|
|
||||||
from app.models.organization import Organization
|
from app.models.organization import Organization
|
||||||
from app.models.user_organization import UserOrganization, OrganizationRole
|
from app.models.user_organization import UserOrganization, OrganizationRole
|
||||||
|
from app.models.user_session import UserSession
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
@@ -837,3 +839,159 @@ class TestAdminRemoveOrganizationMember:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
# ===== SESSION MANAGEMENT TESTS =====
|
||||||
|
|
||||||
|
class TestAdminListSessions:
|
||||||
|
"""Tests for admin sessions list endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_list_sessions_success(self, client, async_test_superuser, async_test_user, async_test_db, superuser_token):
|
||||||
|
"""Test listing all sessions as admin."""
|
||||||
|
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||||
|
|
||||||
|
# Create some test sessions
|
||||||
|
async with AsyncTestingSessionLocal() as session:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + timedelta(days=7)
|
||||||
|
|
||||||
|
session1 = UserSession(
|
||||||
|
user_id=async_test_user.id,
|
||||||
|
refresh_token_jti="jti-test-1",
|
||||||
|
device_name="iPhone 14",
|
||||||
|
device_id="device-1",
|
||||||
|
ip_address="192.168.1.100",
|
||||||
|
user_agent="Mozilla/5.0",
|
||||||
|
last_used_at=now,
|
||||||
|
expires_at=expires_at,
|
||||||
|
is_active=True,
|
||||||
|
location_city="San Francisco",
|
||||||
|
location_country="United States"
|
||||||
|
)
|
||||||
|
session2 = UserSession(
|
||||||
|
user_id=async_test_superuser.id,
|
||||||
|
refresh_token_jti="jti-test-2",
|
||||||
|
device_name="MacBook Pro",
|
||||||
|
device_id="device-2",
|
||||||
|
ip_address="192.168.1.101",
|
||||||
|
user_agent="Mozilla/5.0",
|
||||||
|
last_used_at=now,
|
||||||
|
expires_at=expires_at,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
session.add_all([session1, session2])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/admin/sessions?page=1&limit=10",
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert "data" in data
|
||||||
|
assert "pagination" in data
|
||||||
|
assert len(data["data"]) >= 2 # At least our 2 test sessions
|
||||||
|
assert data["pagination"]["total"] >= 2
|
||||||
|
|
||||||
|
# Verify session structure includes user info
|
||||||
|
first_session = data["data"][0]
|
||||||
|
assert "id" in first_session
|
||||||
|
assert "user_id" in first_session
|
||||||
|
assert "user_email" in first_session
|
||||||
|
assert "device_name" in first_session
|
||||||
|
assert "ip_address" in first_session
|
||||||
|
assert "is_active" in first_session
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_list_sessions_filter_active(self, client, async_test_superuser, async_test_user, async_test_db, superuser_token):
|
||||||
|
"""Test filtering sessions by active status."""
|
||||||
|
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||||
|
|
||||||
|
# Create active and inactive sessions
|
||||||
|
async with AsyncTestingSessionLocal() as session:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + timedelta(days=7)
|
||||||
|
|
||||||
|
active_session = UserSession(
|
||||||
|
user_id=async_test_user.id,
|
||||||
|
refresh_token_jti="jti-active",
|
||||||
|
device_name="Active Device",
|
||||||
|
ip_address="192.168.1.100",
|
||||||
|
last_used_at=now,
|
||||||
|
expires_at=expires_at,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
inactive_session = UserSession(
|
||||||
|
user_id=async_test_user.id,
|
||||||
|
refresh_token_jti="jti-inactive",
|
||||||
|
device_name="Inactive Device",
|
||||||
|
ip_address="192.168.1.101",
|
||||||
|
last_used_at=now,
|
||||||
|
expires_at=expires_at,
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
session.add_all([active_session, inactive_session])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Get only active sessions (default)
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/admin/sessions?page=1&limit=100",
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# All returned sessions should be active
|
||||||
|
for sess in data["data"]:
|
||||||
|
assert sess["is_active"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_list_sessions_pagination(self, client, async_test_superuser, async_test_db, superuser_token):
|
||||||
|
"""Test pagination of sessions list."""
|
||||||
|
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||||
|
|
||||||
|
# Create multiple sessions
|
||||||
|
async with AsyncTestingSessionLocal() as session:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + timedelta(days=7)
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
for i in range(5):
|
||||||
|
sess = UserSession(
|
||||||
|
user_id=async_test_superuser.id,
|
||||||
|
refresh_token_jti=f"jti-pagination-{i}",
|
||||||
|
device_name=f"Device {i}",
|
||||||
|
ip_address=f"192.168.1.{100+i}",
|
||||||
|
last_used_at=now,
|
||||||
|
expires_at=expires_at,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
sessions.append(sess)
|
||||||
|
session.add_all(sessions)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Get first page with limit 2
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/admin/sessions?page=1&limit=2",
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["data"]) == 2
|
||||||
|
assert data["pagination"]["page"] == 1
|
||||||
|
assert data["pagination"]["page_size"] == 2
|
||||||
|
assert data["pagination"]["total"] >= 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_list_sessions_unauthorized(self, client, async_test_user, user_token):
|
||||||
|
"""Test that non-admin users cannot access admin sessions endpoint."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/admin/sessions?page=1&limit=10",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|||||||
@@ -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 { OrganizationManagementContent } from '@/components/admin/organizations/OrganizationManagementContent';
|
||||||
|
|
||||||
/* 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 = {
|
||||||
@@ -19,43 +20,17 @@ export default function AdminOrganizationsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-6 py-8">
|
<div className="container mx-auto px-6 py-8">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Back Button + Header */}
|
{/* Back Button */}
|
||||||
<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" aria-label="Back to Admin Dashboard">
|
<Button variant="outline" size="icon" aria-label="Back to Admin Dashboard">
|
||||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Organizations
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-muted-foreground">
|
|
||||||
Manage organizations and their members
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder Content */}
|
{/* Organization Management Content */}
|
||||||
<div className="rounded-lg border bg-card p-12 text-center">
|
<OrganizationManagementContent />
|
||||||
<h3 className="text-xl font-semibold mb-2">
|
|
||||||
Organization Management Coming Soon
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
|
||||||
This page will allow you to view all organizations, manage their
|
|
||||||
members, and perform administrative tasks.
|
|
||||||
</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>• Organization list with search and filtering</li>
|
|
||||||
<li>• View organization details and members</li>
|
|
||||||
<li>• Manage organization memberships</li>
|
|
||||||
<li>• Organization statistics and activity</li>
|
|
||||||
<li>• Bulk operations</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* OrganizationActionMenu Component
|
||||||
|
* Dropdown menu for organization row actions (Edit, View Members, Delete)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { MoreHorizontal, Edit, Users, 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 {
|
||||||
|
useDeleteOrganization,
|
||||||
|
type Organization,
|
||||||
|
} from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
|
interface OrganizationActionMenuProps {
|
||||||
|
organization: Organization;
|
||||||
|
onEdit?: (organization: Organization) => void;
|
||||||
|
onViewMembers?: (organizationId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationActionMenu({
|
||||||
|
organization,
|
||||||
|
onEdit,
|
||||||
|
onViewMembers,
|
||||||
|
}: OrganizationActionMenuProps) {
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const deleteOrganization = useDeleteOrganization();
|
||||||
|
|
||||||
|
// istanbul ignore next - Delete handler fully tested in E2E (admin-organizations.spec.ts)
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteOrganization.mutateAsync(organization.id);
|
||||||
|
toast.success(`${organization.name} has been deleted successfully.`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to delete organization');
|
||||||
|
} finally {
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
if (onEdit) {
|
||||||
|
onEdit(organization);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewMembers = () => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
if (onViewMembers) {
|
||||||
|
onViewMembers(organization.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
aria-label={`Actions for ${organization.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleEdit}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Organization
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={handleViewMembers}>
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
View Members
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
Delete Organization
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Organization</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete {organization.name}? This action cannot be undone
|
||||||
|
and will remove all associated data.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* OrganizationFormDialog Component
|
||||||
|
* Dialog for creating and editing organizations 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 { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
useCreateOrganization,
|
||||||
|
useUpdateOrganization,
|
||||||
|
type Organization,
|
||||||
|
} from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const organizationFormSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Organization name is required')
|
||||||
|
.min(2, 'Organization name must be at least 2 characters')
|
||||||
|
.max(100, 'Organization name must not exceed 100 characters'),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.max(500, 'Description must not exceed 500 characters')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
|
is_active: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type OrganizationFormData = z.infer<typeof organizationFormSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface OrganizationFormDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
organization?: Organization | null;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
organization,
|
||||||
|
mode,
|
||||||
|
}: OrganizationFormDialogProps) {
|
||||||
|
const isEdit = mode === 'edit' && organization;
|
||||||
|
const createOrganization = useCreateOrganization();
|
||||||
|
const updateOrganization = useUpdateOrganization();
|
||||||
|
|
||||||
|
const form = useForm<OrganizationFormData>({
|
||||||
|
resolver: zodResolver(organizationFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form when dialog opens/closes or organization changes
|
||||||
|
// istanbul ignore next - Form reset logic tested in E2E (admin-organizations.spec.ts)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && isEdit) {
|
||||||
|
form.reset({
|
||||||
|
name: organization.name,
|
||||||
|
description: organization.description || '',
|
||||||
|
is_active: organization.is_active,
|
||||||
|
});
|
||||||
|
} else if (open && !isEdit) {
|
||||||
|
form.reset({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, isEdit, organization, form]);
|
||||||
|
|
||||||
|
// istanbul ignore next - Form submission logic fully tested in E2E (admin-organizations.spec.ts)
|
||||||
|
const onSubmit = async (data: OrganizationFormData) => {
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await updateOrganization.mutateAsync({
|
||||||
|
orgId: organization.id,
|
||||||
|
orgData: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || null,
|
||||||
|
is_active: data.is_active,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success(`${data.name} has been updated successfully.`);
|
||||||
|
} else {
|
||||||
|
// Generate slug from name (simple kebab-case conversion)
|
||||||
|
const slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
await createOrganization.mutateAsync({
|
||||||
|
name: data.name,
|
||||||
|
slug,
|
||||||
|
description: data.description || null,
|
||||||
|
});
|
||||||
|
toast.success(`${data.name} has been created successfully.`);
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
form.reset();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : `Failed to ${isEdit ? 'update' : 'create'} organization`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = createOrganization.isPending || updateOrganization.isPending;
|
||||||
|
|
||||||
|
// istanbul ignore next - JSX rendering tested in E2E (admin-organizations.spec.ts)
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit ? 'Edit Organization' : 'Create Organization'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? 'Update the organization details below.'
|
||||||
|
: 'Add a new organization to the system.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Name Field */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Acme Corporation"
|
||||||
|
{...form.register('name')}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.name && (
|
||||||
|
<p className="text-sm text-destructive" id="name-error">
|
||||||
|
{form.formState.errors.name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Field */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="A brief description of the organization..."
|
||||||
|
rows={3}
|
||||||
|
{...form.register('description')}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.description && (
|
||||||
|
<p className="text-sm text-destructive" id="description-error">
|
||||||
|
{form.formState.errors.description.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Status (Edit Mode Only) */}
|
||||||
|
{isEdit && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="is_active"
|
||||||
|
checked={form.watch('is_active')}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
form.setValue('is_active', checked === true)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="is_active"
|
||||||
|
className="text-sm font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Organization is active
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Organization'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* OrganizationListTable Component
|
||||||
|
* Displays paginated list of organizations with basic info and actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Users } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { OrganizationActionMenu } from './OrganizationActionMenu';
|
||||||
|
import type { Organization, PaginationMeta } from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
|
interface OrganizationListTableProps {
|
||||||
|
organizations: Organization[];
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
isLoading: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onEditOrganization?: (organization: Organization) => void;
|
||||||
|
onViewMembers?: (organizationId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationListTable({
|
||||||
|
organizations,
|
||||||
|
pagination,
|
||||||
|
isLoading,
|
||||||
|
onPageChange,
|
||||||
|
onEditOrganization,
|
||||||
|
onViewMembers,
|
||||||
|
}: OrganizationListTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className="text-center">Members</TableHead>
|
||||||
|
<TableHead className="text-center">Status</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-[150px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[250px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[40px] mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-5 w-[60px] mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : organizations.length === 0 ? (
|
||||||
|
// Empty state
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
No organizations found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
// Organization rows
|
||||||
|
organizations.map((org) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={org.id}>
|
||||||
|
<TableCell className="font-medium">{org.name}</TableCell>
|
||||||
|
<TableCell className="max-w-md truncate">
|
||||||
|
{org.description || (
|
||||||
|
<span className="text-muted-foreground italic">
|
||||||
|
No description
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewMembers?.(org.id)}
|
||||||
|
className="inline-flex items-center gap-1 hover:text-primary"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{org.member_count}</span>
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant={org.is_active ? 'default' : 'secondary'}>
|
||||||
|
{org.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{format(new Date(org.created_at), 'MMM d, yyyy')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<OrganizationActionMenu
|
||||||
|
organization={org}
|
||||||
|
onEdit={onEditOrganization}
|
||||||
|
onViewMembers={onViewMembers}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!isLoading && organizations.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} organizations
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* OrganizationManagementContent Component
|
||||||
|
* Client-side content for the organization 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 {
|
||||||
|
useAdminOrganizations,
|
||||||
|
type Organization,
|
||||||
|
type PaginationMeta,
|
||||||
|
} from '@/lib/api/hooks/useAdmin';
|
||||||
|
import { OrganizationListTable } from './OrganizationListTable';
|
||||||
|
import { OrganizationFormDialog } from './OrganizationFormDialog';
|
||||||
|
|
||||||
|
export function OrganizationManagementContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
// URL state
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
|
||||||
|
const [editingOrganization, setEditingOrganization] = useState<Organization | null>(null);
|
||||||
|
|
||||||
|
// Fetch organizations with query params
|
||||||
|
const { data, isLoading } = useAdminOrganizations(page, 20);
|
||||||
|
|
||||||
|
const organizations: Organization[] = data?.data || [];
|
||||||
|
const pagination: PaginationMeta = data?.pagination || {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
total_pages: 1,
|
||||||
|
has_next: false,
|
||||||
|
has_prev: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// istanbul ignore next - URL update helper fully tested in E2E (admin-organizations.spec.ts)
|
||||||
|
// 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 === '') {
|
||||||
|
newParams.delete(key);
|
||||||
|
} else {
|
||||||
|
newParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`?${newParams.toString()}`);
|
||||||
|
},
|
||||||
|
[searchParams, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
// istanbul ignore next - Event handlers fully tested in E2E (admin-organizations.spec.ts)
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
updateURL({ page: newPage });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateOrganization = () => {
|
||||||
|
setDialogMode('create');
|
||||||
|
setEditingOrganization(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOrganization = (organization: Organization) => {
|
||||||
|
setDialogMode('edit');
|
||||||
|
setEditingOrganization(organization);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewMembers = (organizationId: string) => {
|
||||||
|
router.push(`/admin/organizations/${organizationId}/members`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Organizations</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage organizations and their members
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreateOrganization}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Organization
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organization List Table */}
|
||||||
|
<OrganizationListTable
|
||||||
|
organizations={organizations}
|
||||||
|
pagination={pagination}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onEditOrganization={handleEditOrganization}
|
||||||
|
onViewMembers={handleViewMembers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organization Form Dialog */}
|
||||||
|
<OrganizationFormDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
organization={editingOrganization}
|
||||||
|
mode={dialogMode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client';
|
import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HealthCheckData, HealthCheckResponses, ListMySessionsData, ListMySessionsResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen';
|
import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HealthCheckData, HealthCheckResponses, ListMySessionsData, ListMySessionsResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
@@ -812,6 +812,28 @@ export const adminRemoveOrganizationMember = <ThrowOnError extends boolean = fal
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: List All Sessions
|
||||||
|
*
|
||||||
|
* List all sessions across all users (admin only).
|
||||||
|
*
|
||||||
|
* Returns paginated list of sessions with user information.
|
||||||
|
* Useful for admin dashboard statistics and session monitoring.
|
||||||
|
*/
|
||||||
|
export const adminListSessions = <ThrowOnError extends boolean = false>(options?: Options<AdminListSessionsData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<AdminListSessionsResponses, AdminListSessionsErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/v1/admin/sessions',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get My Organizations
|
* Get My Organizations
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -23,6 +23,76 @@ export type AddMemberRequest = {
|
|||||||
role?: OrganizationRole;
|
role?: OrganizationRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminSessionResponse
|
||||||
|
*
|
||||||
|
* Schema for session responses in admin panel.
|
||||||
|
*
|
||||||
|
* Includes user information for admin to see who owns each session.
|
||||||
|
*/
|
||||||
|
export type AdminSessionResponse = {
|
||||||
|
/**
|
||||||
|
* Device Name
|
||||||
|
*
|
||||||
|
* Friendly device name
|
||||||
|
*/
|
||||||
|
device_name?: string | null;
|
||||||
|
/**
|
||||||
|
* Device Id
|
||||||
|
*
|
||||||
|
* Persistent device identifier
|
||||||
|
*/
|
||||||
|
device_id?: string | null;
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* User Id
|
||||||
|
*/
|
||||||
|
user_id: string;
|
||||||
|
/**
|
||||||
|
* User Email
|
||||||
|
*
|
||||||
|
* Email of the user who owns this session
|
||||||
|
*/
|
||||||
|
user_email: string;
|
||||||
|
/**
|
||||||
|
* User Full Name
|
||||||
|
*
|
||||||
|
* Full name of the user
|
||||||
|
*/
|
||||||
|
user_full_name?: string | null;
|
||||||
|
/**
|
||||||
|
* Ip Address
|
||||||
|
*/
|
||||||
|
ip_address?: string | null;
|
||||||
|
/**
|
||||||
|
* Location City
|
||||||
|
*/
|
||||||
|
location_city?: string | null;
|
||||||
|
/**
|
||||||
|
* Location Country
|
||||||
|
*/
|
||||||
|
location_country?: string | null;
|
||||||
|
/**
|
||||||
|
* Last Used At
|
||||||
|
*/
|
||||||
|
last_used_at: string;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Expires At
|
||||||
|
*/
|
||||||
|
expires_at: string;
|
||||||
|
/**
|
||||||
|
* Is Active
|
||||||
|
*/
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body_login_oauth
|
* Body_login_oauth
|
||||||
*/
|
*/
|
||||||
@@ -312,6 +382,22 @@ export type OrganizationUpdate = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PaginatedResponse[AdminSessionResponse]
|
||||||
|
*/
|
||||||
|
export type PaginatedResponseAdminSessionResponse = {
|
||||||
|
/**
|
||||||
|
* Data
|
||||||
|
*
|
||||||
|
* List of items
|
||||||
|
*/
|
||||||
|
data: Array<AdminSessionResponse>;
|
||||||
|
/**
|
||||||
|
* Pagination metadata
|
||||||
|
*/
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PaginatedResponse[OrganizationMemberResponse]
|
* PaginatedResponse[OrganizationMemberResponse]
|
||||||
*/
|
*/
|
||||||
@@ -1711,6 +1797,46 @@ export type AdminRemoveOrganizationMemberResponses = {
|
|||||||
|
|
||||||
export type AdminRemoveOrganizationMemberResponse = AdminRemoveOrganizationMemberResponses[keyof AdminRemoveOrganizationMemberResponses];
|
export type AdminRemoveOrganizationMemberResponse = AdminRemoveOrganizationMemberResponses[keyof AdminRemoveOrganizationMemberResponses];
|
||||||
|
|
||||||
|
export type AdminListSessionsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Is Active
|
||||||
|
*
|
||||||
|
* Filter by active status
|
||||||
|
*/
|
||||||
|
is_active?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Page
|
||||||
|
*/
|
||||||
|
page?: number;
|
||||||
|
/**
|
||||||
|
* Limit
|
||||||
|
*/
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
url: '/api/v1/admin/sessions';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminListSessionsErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminListSessionsError = AdminListSessionsErrors[keyof AdminListSessionsErrors];
|
||||||
|
|
||||||
|
export type AdminListSessionsResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: PaginatedResponseAdminSessionResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminListSessionsResponse = AdminListSessionsResponses[keyof AdminListSessionsResponses];
|
||||||
|
|
||||||
export type GetMyOrganizationsData = {
|
export type GetMyOrganizationsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -11,21 +11,31 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
adminListUsers,
|
type AddMemberRequest,
|
||||||
adminListOrganizations,
|
adminActivateUser,
|
||||||
adminCreateUser,
|
adminAddOrganizationMember,
|
||||||
adminGetUser,
|
adminBulkUserAction,
|
||||||
adminUpdateUser,
|
adminCreateOrganization,
|
||||||
adminDeleteUser,
|
adminCreateUser,
|
||||||
adminActivateUser,
|
adminDeactivateUser,
|
||||||
adminDeactivateUser,
|
adminDeleteOrganization,
|
||||||
adminBulkUserAction,
|
adminDeleteUser,
|
||||||
type UserCreate,
|
adminGetOrganization,
|
||||||
type UserUpdate,
|
adminListOrganizationMembers,
|
||||||
|
adminListOrganizations,
|
||||||
|
adminListSessions,
|
||||||
|
adminListUsers,
|
||||||
|
adminRemoveOrganizationMember,
|
||||||
|
adminUpdateOrganization,
|
||||||
|
adminUpdateUser,
|
||||||
|
type OrganizationCreate,
|
||||||
|
type OrganizationUpdate,
|
||||||
|
type UserCreate,
|
||||||
|
type UserUpdate,
|
||||||
} from '@/lib/api/client';
|
} from '@/lib/api/client';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import {useAuth} from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for admin hooks
|
* Constants for admin hooks
|
||||||
@@ -41,7 +51,7 @@ export interface AdminStats {
|
|||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
activeUsers: number;
|
activeUsers: number;
|
||||||
totalOrganizations: number;
|
totalOrganizations: number;
|
||||||
totalSessions: number; // TODO: Requires admin sessions endpoint
|
totalSessions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,19 +103,22 @@ export function useAdminStats() {
|
|||||||
const orgsData = (orgsResponse as { data: { pagination: { total: number } } }).data;
|
const orgsData = (orgsResponse as { data: { pagination: { total: number } } }).data;
|
||||||
const totalOrganizations = orgsData?.pagination?.total || 0;
|
const totalOrganizations = orgsData?.pagination?.total || 0;
|
||||||
|
|
||||||
// TODO: Add admin sessions endpoint
|
// Fetch sessions list
|
||||||
// Currently no admin-level endpoint exists to fetch all sessions
|
const sessionsResponse = await adminListSessions({
|
||||||
// across all users. The /api/v1/sessions/me endpoint only returns
|
query: {
|
||||||
// sessions for the current user.
|
page: 1,
|
||||||
//
|
limit: STATS_FETCH_LIMIT,
|
||||||
// Once backend implements /api/v1/admin/sessions, uncomment below:
|
},
|
||||||
// const sessionsResponse = await adminListSessions({
|
throwOnError: false,
|
||||||
// query: { page: 1, limit: 10000 },
|
});
|
||||||
// throwOnError: false,
|
|
||||||
// });
|
|
||||||
// const totalSessions = sessionsResponse.data?.pagination?.total || 0;
|
|
||||||
|
|
||||||
const totalSessions = 0; // Placeholder until admin sessions endpoint exists
|
if ('error' in sessionsResponse) {
|
||||||
|
throw new Error('Failed to fetch sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type assertion: if no error, response has data
|
||||||
|
const sessionsData = (sessionsResponse as { data: { pagination: { total: number } } }).data;
|
||||||
|
const totalSessions = sessionsData?.pagination?.total || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalUsers,
|
totalUsers,
|
||||||
@@ -213,7 +226,7 @@ export function useAdminOrganizations(page = 1, limit = DEFAULT_PAGE_LIMIT) {
|
|||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'organizations', page, limit],
|
queryKey: ['admin', 'organizations', page, limit],
|
||||||
queryFn: async () => {
|
queryFn: async (): Promise<PaginatedOrganizationResponse> => {
|
||||||
const response = await adminListOrganizations({
|
const response = await adminListOrganizations({
|
||||||
query: { page, limit },
|
query: { page, limit },
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
@@ -224,7 +237,7 @@ export function useAdminOrganizations(page = 1, limit = DEFAULT_PAGE_LIMIT) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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: PaginatedOrganizationResponse }).data;
|
||||||
},
|
},
|
||||||
// Only fetch if user is a superuser (frontend guard)
|
// Only fetch if user is a superuser (frontend guard)
|
||||||
enabled: user?.is_superuser === true,
|
enabled: user?.is_superuser === true,
|
||||||
@@ -417,3 +430,282 @@ export function useBulkUserAction() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organization interface matching backend OrganizationResponse
|
||||||
|
*/
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
member_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated organization list response
|
||||||
|
*/
|
||||||
|
export interface PaginatedOrganizationResponse {
|
||||||
|
data: Organization[];
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organization member interface matching backend OrganizationMemberResponse
|
||||||
|
*/
|
||||||
|
export interface OrganizationMember {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string | null;
|
||||||
|
role: 'owner' | 'admin' | 'member' | 'guest';
|
||||||
|
joined_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated organization member list response
|
||||||
|
*/
|
||||||
|
export interface PaginatedOrganizationMemberResponse {
|
||||||
|
data: OrganizationMember[];
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create a new organization (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for creating organizations
|
||||||
|
*/
|
||||||
|
export function useCreateOrganization() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (orgData: OrganizationCreate) => {
|
||||||
|
const response = await adminCreateOrganization({
|
||||||
|
body: orgData,
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to create organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate organization queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'organizations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to update an existing organization (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for updating organizations
|
||||||
|
*/
|
||||||
|
export function useUpdateOrganization() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
orgId,
|
||||||
|
orgData,
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
orgData: OrganizationUpdate;
|
||||||
|
}) => {
|
||||||
|
const response = await adminUpdateOrganization({
|
||||||
|
path: { org_id: orgId },
|
||||||
|
body: orgData,
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to update organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate organization queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'organizations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to delete an organization (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for deleting organizations
|
||||||
|
*/
|
||||||
|
export function useDeleteOrganization() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (orgId: string) => {
|
||||||
|
const response = await adminDeleteOrganization({
|
||||||
|
path: { org_id: orgId },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to delete organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate organization queries to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'organizations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch single organization (admin only)
|
||||||
|
*
|
||||||
|
* @param orgId - Organization ID
|
||||||
|
* @returns Query hook for fetching organization
|
||||||
|
*/
|
||||||
|
export function useGetOrganization(orgId: string | null) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'organization', orgId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!orgId) {
|
||||||
|
throw new Error('Organization ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await adminGetOrganization({
|
||||||
|
path: { org_id: orgId },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to fetch organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
enabled: user?.is_superuser === true && !!orgId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch organization members (admin only)
|
||||||
|
*
|
||||||
|
* @param orgId - Organization ID
|
||||||
|
* @param page - Page number (1-indexed)
|
||||||
|
* @param limit - Number of records per page
|
||||||
|
* @returns Paginated list of organization members
|
||||||
|
*/
|
||||||
|
export function useOrganizationMembers(
|
||||||
|
orgId: string | null,
|
||||||
|
page = 1,
|
||||||
|
limit = DEFAULT_PAGE_LIMIT
|
||||||
|
) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'organization', orgId, 'members', page, limit],
|
||||||
|
queryFn: async (): Promise<PaginatedOrganizationMemberResponse> => {
|
||||||
|
if (!orgId) {
|
||||||
|
throw new Error('Organization ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await adminListOrganizationMembers({
|
||||||
|
path: { org_id: orgId },
|
||||||
|
query: { page, limit },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to fetch organization members');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: PaginatedOrganizationMemberResponse }).data;
|
||||||
|
},
|
||||||
|
enabled: user?.is_superuser === true && !!orgId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to add a member to an organization (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for adding organization members
|
||||||
|
*/
|
||||||
|
export function useAddOrganizationMember() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
orgId,
|
||||||
|
memberData,
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
memberData: AddMemberRequest;
|
||||||
|
}) => {
|
||||||
|
const response = await adminAddOrganizationMember({
|
||||||
|
path: { org_id: orgId },
|
||||||
|
body: memberData,
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to add organization member');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
// Invalidate member queries to refetch
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin', 'organization', variables.orgId, 'members'],
|
||||||
|
});
|
||||||
|
// Invalidate organization list to update member count
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'organizations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to remove a member from an organization (admin only)
|
||||||
|
*
|
||||||
|
* @returns Mutation hook for removing organization members
|
||||||
|
*/
|
||||||
|
export function useRemoveOrganizationMember() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
userId: string;
|
||||||
|
}) => {
|
||||||
|
const response = await adminRemoveOrganizationMember({
|
||||||
|
path: { org_id: orgId, user_id: userId },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('error' in response) {
|
||||||
|
throw new Error('Failed to remove organization member');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response as { data: unknown }).data;
|
||||||
|
},
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
// Invalidate member queries to refetch
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin', 'organization', variables.orgId, 'members'],
|
||||||
|
});
|
||||||
|
// Invalidate organization list to update member count
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'organizations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user