feat: reorganize admin panel around accounts
This commit is contained in:
@@ -5,7 +5,7 @@ from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -24,7 +24,19 @@ from app.models.invite_code import InviteCode
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.tree import Tree
|
||||
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
|
||||
from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse
|
||||
from app.schemas.admin import (
|
||||
MoveUserAccount,
|
||||
AdminUserCreate,
|
||||
AdminUserCreateResponse,
|
||||
AdminPasswordReset,
|
||||
AdminPasswordResetResponse,
|
||||
HardDeleteCheckResponse,
|
||||
AdminUserListItem,
|
||||
AdminUserListResponse,
|
||||
AdminAccountMember,
|
||||
AdminAccountListItem,
|
||||
AdminAccountListResponse,
|
||||
)
|
||||
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
|
||||
from app.schemas.user_detail import (
|
||||
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
||||
@@ -35,10 +47,13 @@ from app.api.deps import require_admin
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
@router.get("/users", response_model=AdminUserListResponse)
|
||||
async def list_users(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
page: Optional[int] = Query(None, ge=1),
|
||||
size: Optional[int] = Query(None, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, description="Search by user or account fields"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
@@ -46,23 +61,154 @@ async def list_users(
|
||||
account_id: Optional[UUID] = Query(None, description="Filter by account"),
|
||||
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
|
||||
):
|
||||
"""List all users (super admin only)."""
|
||||
query = select(User)
|
||||
"""List users for super admin global people search."""
|
||||
resolved_limit = size or limit
|
||||
resolved_skip = skip
|
||||
current_page = 1
|
||||
|
||||
if page is not None:
|
||||
resolved_skip = (page - 1) * resolved_limit
|
||||
current_page = page
|
||||
elif resolved_limit > 0:
|
||||
current_page = (resolved_skip // resolved_limit) + 1
|
||||
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.outerjoin(Account, User.account_id == Account.id)
|
||||
)
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
Account.name.label("account_name"),
|
||||
Account.display_code.label("account_display_code"),
|
||||
)
|
||||
.outerjoin(Account, User.account_id == Account.id)
|
||||
)
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(User.deleted_at.is_(None))
|
||||
count_query = count_query.where(User.deleted_at.is_(None))
|
||||
if is_active is not None:
|
||||
query = query.where(User.is_active == is_active)
|
||||
count_query = count_query.where(User.is_active == is_active)
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
count_query = count_query.where(User.role == role)
|
||||
if account_id:
|
||||
query = query.where(User.account_id == account_id)
|
||||
count_query = count_query.where(User.account_id == account_id)
|
||||
if search:
|
||||
search_term = f"%{search.strip()}%"
|
||||
search_filter = or_(
|
||||
User.name.ilike(search_term),
|
||||
User.email.ilike(search_term),
|
||||
Account.name.ilike(search_term),
|
||||
Account.display_code.ilike(search_term),
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(skip).limit(limit)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
return users
|
||||
rows = result.all()
|
||||
|
||||
items = [
|
||||
AdminUserListItem(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
is_super_admin=user.is_super_admin,
|
||||
is_active=user.is_active,
|
||||
account_id=user.account_id,
|
||||
account_role=user.account_role,
|
||||
account_name=account_name,
|
||||
account_display_code=account_display_code,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
deleted_at=user.deleted_at,
|
||||
)
|
||||
for user, account_name, account_display_code in rows
|
||||
]
|
||||
|
||||
return AdminUserListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=current_page,
|
||||
per_page=resolved_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=AdminAccountListResponse)
|
||||
async def list_accounts(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(12, ge=1, le=100),
|
||||
include_archived: bool = Query(False, description="Include archived users in account member lists"),
|
||||
):
|
||||
"""List accounts with embedded members for the admin panel."""
|
||||
total_result = await db.execute(select(func.count()).select_from(Account))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
accounts_result = await db.execute(
|
||||
select(Account)
|
||||
.order_by(Account.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
accounts = accounts_result.scalars().all()
|
||||
|
||||
account_ids = [account.id for account in accounts]
|
||||
members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids}
|
||||
|
||||
if account_ids:
|
||||
members_query = select(User).where(User.account_id.in_(account_ids))
|
||||
if not include_archived:
|
||||
members_query = members_query.where(User.deleted_at.is_(None))
|
||||
members_query = members_query.order_by(User.created_at.asc())
|
||||
|
||||
members_result = await db.execute(members_query)
|
||||
for member in members_result.scalars().all():
|
||||
members_by_account.setdefault(member.account_id, []).append(
|
||||
AdminAccountMember(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
is_super_admin=member.is_super_admin,
|
||||
is_active=member.is_active,
|
||||
account_role=member.account_role,
|
||||
created_at=member.created_at,
|
||||
last_login=member.last_login,
|
||||
deleted_at=member.deleted_at,
|
||||
)
|
||||
)
|
||||
|
||||
items = [
|
||||
AdminAccountListItem(
|
||||
id=account.id,
|
||||
name=account.name,
|
||||
display_code=account.display_code,
|
||||
created_at=account.created_at,
|
||||
owner_id=account.owner_id,
|
||||
member_count=len(members_by_account.get(account.id, [])),
|
||||
active_member_count=sum(1 for member in members_by_account.get(account.id, []) if member.is_active),
|
||||
members=members_by_account.get(account.id, []),
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
return AdminAccountListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=size,
|
||||
)
|
||||
|
||||
|
||||
def _generate_display_code() -> str:
|
||||
|
||||
@@ -28,6 +28,62 @@ class ActivityEntry(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --- Admin Accounts & People Search ---
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: str
|
||||
is_super_admin: bool = False
|
||||
is_active: bool = True
|
||||
account_id: Optional[UUID] = None
|
||||
account_role: Optional[str] = None
|
||||
account_name: Optional[str] = None
|
||||
account_display_code: Optional[str] = None
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
items: list[AdminUserListItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
class AdminAccountMember(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: str
|
||||
is_super_admin: bool = False
|
||||
is_active: bool = True
|
||||
account_role: Optional[str] = None
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminAccountListItem(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
display_code: str
|
||||
created_at: datetime
|
||||
owner_id: Optional[UUID] = None
|
||||
member_count: int = 0
|
||||
active_member_count: int = 0
|
||||
members: list[AdminAccountMember] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminAccountListResponse(BaseModel):
|
||||
items: list[AdminAccountListItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
# --- Audit Logs ---
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
|
||||
@@ -19,8 +19,38 @@ class TestAdminEndpoints:
|
||||
"/api/v1/admin/users", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
users = response.json()
|
||||
assert len(users) >= 2 # admin + test_user
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 2 # admin + test_user
|
||||
assert len(payload["items"]) >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_supports_search(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test admin people search by user email."""
|
||||
response = await client.get(
|
||||
"/api/v1/admin/users",
|
||||
params={"search": test_user["email"]},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 1
|
||||
assert any(item["email"] == test_user["email"] for item in payload["items"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Test listing accounts with member data."""
|
||||
response = await client.get(
|
||||
"/api/v1/admin/accounts", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 1
|
||||
assert len(payload["items"]) >= 1
|
||||
assert "members" in payload["items"][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_as_non_admin(
|
||||
|
||||
@@ -2,6 +2,8 @@ import api from './client'
|
||||
import type {
|
||||
DashboardMetrics,
|
||||
ActivityEntry,
|
||||
AdminUserListResponse,
|
||||
AdminAccountListResponse,
|
||||
AuditLogListResponse,
|
||||
PlanLimitConfig,
|
||||
AccountOverrideResponse,
|
||||
@@ -78,7 +80,9 @@ export const adminApi = {
|
||||
createUser: (data: AdminUserCreate) =>
|
||||
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
|
||||
listUsers: (params?: Record<string, unknown>) =>
|
||||
api.get('/admin/users', { params }).then(r => r.data),
|
||||
api.get<AdminUserListResponse>('/admin/users', { params }).then(r => r.data),
|
||||
listAccounts: (params?: Record<string, unknown>) =>
|
||||
api.get<AdminAccountListResponse>('/admin/accounts', { params }).then(r => r.data),
|
||||
getUser: (id: string) =>
|
||||
api.get(`/admin/users/${id}`).then(r => r.data),
|
||||
updateUserRole: (id: string, role: string) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Building2,
|
||||
Ticket,
|
||||
FileText,
|
||||
Gauge,
|
||||
@@ -17,7 +17,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ path: '/admin/users', label: 'Users', icon: Users },
|
||||
{ path: '/admin/accounts', label: 'Accounts', icon: Building2 },
|
||||
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
|
||||
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
|
||||
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react'
|
||||
import { Users, TreePine, CreditCard, Activity, TrendingUp, Building2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageHeader } from '@/components/admin'
|
||||
import { adminApi } from '@/api/admin'
|
||||
@@ -43,7 +43,7 @@ export function DashboardPage() {
|
||||
}, [])
|
||||
|
||||
const quickLinks = [
|
||||
{ to: '/admin/users', label: 'Manage Users', icon: Users },
|
||||
{ to: '/admin/accounts', label: 'Manage Accounts', icon: Building2 },
|
||||
{ to: '/admin/plan-limits', label: 'Plan Limits', icon: TrendingUp },
|
||||
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
|
||||
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
|
||||
|
||||
@@ -177,7 +177,7 @@ export function UserDetailPage() {
|
||||
try {
|
||||
await adminApi.hardDeleteUser(userId)
|
||||
toast.success('User permanently deleted')
|
||||
navigate('/admin/users')
|
||||
navigate('/admin/accounts')
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
@@ -207,8 +207,8 @@ export function UserDetailPage() {
|
||||
title="User not found"
|
||||
description="This user may have been removed or is unavailable."
|
||||
action={(
|
||||
<Button variant="secondary" onClick={() => navigate('/admin/users')}>
|
||||
Back to Users
|
||||
<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>
|
||||
Back to Accounts
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -223,7 +223,7 @@ export function UserDetailPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
onClick={() => navigate('/admin/accounts')}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -1,48 +1,64 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink, UserPlus, Copy, Check, Mail } from 'lucide-react'
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Building2,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Mail,
|
||||
Search,
|
||||
Shield,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
UserX,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
|
||||
import type { Column } from '@/components/admin'
|
||||
import { Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AdminAccountListItem, AdminAccountMember, AdminUserListItem } from '@/types/admin'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
is_super_admin: boolean
|
||||
is_active: boolean
|
||||
account_id: string | null
|
||||
account_role: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
deleted_at: string | null
|
||||
type UserRecord = AdminUserListItem | (AdminAccountMember & {
|
||||
account_id: string
|
||||
account_name: string
|
||||
account_display_code: string
|
||||
})
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return 'Never'
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
function buildMemberRecord(account: AdminAccountListItem, member: AdminAccountMember): UserRecord {
|
||||
return {
|
||||
...member,
|
||||
account_id: account.id,
|
||||
account_name: account.name,
|
||||
account_display_code: account.display_code,
|
||||
}
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const navigate = useNavigate()
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [accounts, setAccounts] = useState<AdminAccountListItem[]>([])
|
||||
const [people, setPeople] = useState<AdminUserListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const pageSize = 20
|
||||
const accountPageSize = 8
|
||||
const peoplePageSize = 20
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
// Role change modal
|
||||
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
|
||||
const [roleModalUser, setRoleModalUser] = useState<UserRecord | null>(null)
|
||||
const [newRole, setNewRole] = useState('')
|
||||
|
||||
// Move account modal
|
||||
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
|
||||
const [moveModalUser, setMoveModalUser] = useState<UserRecord | null>(null)
|
||||
const [displayCode, setDisplayCode] = useState('')
|
||||
|
||||
// Create user modal
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
email: '',
|
||||
@@ -53,30 +69,60 @@ export function UsersPage() {
|
||||
send_email: true,
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
|
||||
// Temp password display modal
|
||||
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Invite user modal
|
||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||
const [inviteForm, setInviteForm] = useState({ email: '', account_display_code: '', role: 'engineer' as 'engineer' | 'viewer' })
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
account_display_code: '',
|
||||
role: 'engineer' as 'engineer' | 'viewer',
|
||||
})
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined, include_archived: showArchived || undefined })
|
||||
setUsers(data.items || data)
|
||||
setTotal(data.total || (data.items ? data.items.length : data.length))
|
||||
const data = await adminApi.listAccounts({
|
||||
page,
|
||||
size: accountPageSize,
|
||||
include_archived: showArchived || undefined,
|
||||
})
|
||||
setAccounts(data.items)
|
||||
setPeople([])
|
||||
setTotal(data.total)
|
||||
} catch {
|
||||
toast.error('Failed to load users')
|
||||
toast.error('Failed to load accounts')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, showArchived])
|
||||
}, [accountPageSize, page, showArchived])
|
||||
|
||||
useEffect(() => { fetchUsers() }, [fetchUsers])
|
||||
const fetchPeople = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers({
|
||||
page,
|
||||
size: peoplePageSize,
|
||||
search: search || undefined,
|
||||
include_archived: showArchived || undefined,
|
||||
})
|
||||
setPeople(data.items)
|
||||
setAccounts([])
|
||||
setTotal(data.total)
|
||||
} catch {
|
||||
toast.error('Failed to load people search')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, peoplePageSize, search, showArchived])
|
||||
|
||||
useEffect(() => {
|
||||
if (search.trim()) {
|
||||
fetchPeople()
|
||||
return
|
||||
}
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts, fetchPeople, search])
|
||||
|
||||
const handleRoleChange = async () => {
|
||||
if (!roleModalUser || !newRole) return
|
||||
@@ -84,13 +130,17 @@ export function UsersPage() {
|
||||
await adminApi.updateUserRole(roleModalUser.id, newRole)
|
||||
toast.success('Role updated')
|
||||
setRoleModalUser(null)
|
||||
fetchUsers()
|
||||
if (search.trim()) {
|
||||
fetchPeople()
|
||||
} else {
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to update role')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (user: AdminUser) => {
|
||||
const handleToggleActive = async (user: UserRecord) => {
|
||||
try {
|
||||
if (user.is_active) {
|
||||
await adminApi.deactivateUser(user.id)
|
||||
@@ -99,7 +149,11 @@ export function UsersPage() {
|
||||
await adminApi.activateUser(user.id)
|
||||
toast.success('User activated')
|
||||
}
|
||||
fetchUsers()
|
||||
if (search.trim()) {
|
||||
fetchPeople()
|
||||
} else {
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to update user status')
|
||||
}
|
||||
@@ -112,7 +166,11 @@ export function UsersPage() {
|
||||
toast.success('User moved to account')
|
||||
setMoveModalUser(null)
|
||||
setDisplayCode('')
|
||||
fetchUsers()
|
||||
if (search.trim()) {
|
||||
fetchPeople()
|
||||
} else {
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to move user')
|
||||
}
|
||||
@@ -138,8 +196,19 @@ export function UsersPage() {
|
||||
setTempPassword(result.temporary_password)
|
||||
setCopied(false)
|
||||
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
|
||||
setCreateForm({ email: '', name: '', account_mode: 'personal', account_display_code: '', account_role: 'engineer', send_email: true })
|
||||
fetchUsers()
|
||||
setCreateForm({
|
||||
email: '',
|
||||
name: '',
|
||||
account_mode: 'personal',
|
||||
account_display_code: '',
|
||||
account_role: 'engineer',
|
||||
send_email: true,
|
||||
})
|
||||
if (search.trim()) {
|
||||
fetchPeople()
|
||||
} else {
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
@@ -183,90 +252,75 @@ export function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<AdminUser>[] = [
|
||||
const userActions = (user: UserRecord) => ([
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
render: (u) => (
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{u.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
),
|
||||
label: 'View Detail',
|
||||
icon: <ExternalLink className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/users/${user.id}`),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{u.role}</span>
|
||||
{u.is_super_admin && (
|
||||
<StatusBadge variant="destructive">Super Admin</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
label: 'Change Role',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setRoleModalUser(user)
|
||||
setNewRole(user.role)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
label: user.is_active ? 'Deactivate' : 'Activate',
|
||||
icon: user.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
|
||||
onClick: () => handleToggleActive(user),
|
||||
destructive: user.is_active,
|
||||
},
|
||||
{
|
||||
label: 'Move Account',
|
||||
icon: <ArrowRightLeft className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setMoveModalUser(user)
|
||||
setDisplayCode('')
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const renderUserRow = (user: UserRecord) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border bg-card/70 p-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium text-foreground">{user.name}</p>
|
||||
{user.is_super_admin && <StatusBadge variant="destructive">Super Admin</StatusBadge>}
|
||||
{user.account_role && <StatusBadge variant="default">{user.account_role}</StatusBadge>}
|
||||
<StatusBadge variant={user.is_active ? 'success' : 'destructive'}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
{u.deleted_at && (
|
||||
<StatusBadge variant="warning">Archived</StatusBadge>
|
||||
)}
|
||||
{user.deleted_at && <StatusBadge variant="warning">Archived</StatusBadge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Joined',
|
||||
sortable: true,
|
||||
render: (u) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-12',
|
||||
render: (u) => (
|
||||
<ActionMenu items={[
|
||||
{
|
||||
label: 'View Detail',
|
||||
icon: <ExternalLink className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/users/${u.id}`),
|
||||
},
|
||||
{
|
||||
label: 'Change Role',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
onClick: () => { setRoleModalUser(u); setNewRole(u.role) },
|
||||
},
|
||||
{
|
||||
label: u.is_active ? 'Deactivate' : 'Activate',
|
||||
icon: u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
|
||||
onClick: () => handleToggleActive(u),
|
||||
destructive: u.is_active,
|
||||
},
|
||||
{
|
||||
label: 'Move Account',
|
||||
icon: <ArrowRightLeft className="h-4 w-4" />,
|
||||
onClick: () => { setMoveModalUser(u); setDisplayCode('') },
|
||||
},
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
<p className="mt-1 text-sm text-muted-foreground">{user.email}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Platform role: {user.role}</span>
|
||||
<span>Account: {user.account_name || 'No account'}</span>
|
||||
{user.account_display_code && <span>Code: {user.account_display_code}</span>}
|
||||
<span>Last login: {formatDate(user.last_login)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionMenu items={userActions(user)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const isSearching = Boolean(search.trim())
|
||||
const totalPages = Math.max(1, Math.ceil(total / (isSearching ? peoplePageSize : accountPageSize)))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<PageHeader title="Users" description="Manage platform users and roles" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<PageHeader
|
||||
title="Accounts"
|
||||
description="Manage accounts as the top-level admin object, with members nested inside each account."
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
|
||||
<Mail className="h-4 w-4" />
|
||||
@@ -279,51 +333,146 @@ export function UsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1) }}
|
||||
placeholder="Search by name or email..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => { setShowArchived(e.target.checked); setPage(1) }}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
Show archived
|
||||
</label>
|
||||
<div className="rounded-2xl border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Global People Search
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Search any name or email across every account. Leave it blank to browse accounts and their members.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onSearch={(value) => {
|
||||
setSearch(value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Search people across all accounts..."
|
||||
className="w-full sm:min-w-[320px]"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => {
|
||||
setShowArchived(e.target.checked)
|
||||
setPage(1)
|
||||
}}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
Show archived
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
keyExtractor={(u) => u.id}
|
||||
isLoading={loading}
|
||||
/>
|
||||
{isSearching ? (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-accent p-2 text-muted-foreground">
|
||||
<Search className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">People Results</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading ? 'Searching people...' : `${total} matching people found across all accounts.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{people.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{people.map((person) => renderUserRow(person))}
|
||||
</div>
|
||||
) : !loading ? (
|
||||
<EmptyState
|
||||
icon={<Search className="h-8 w-8" />}
|
||||
title="No people matched that search"
|
||||
description="Try a name, email address, or clear the search to return to the account view."
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
) : (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-accent p-2 text-muted-foreground">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Account Directory</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse accounts first, then work with the users inside each one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accounts.length > 0 ? (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{accounts.map((account) => (
|
||||
<article key={account.id} className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-4 border-b border-border pb-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">{account.name}</h3>
|
||||
<StatusBadge variant="default">{account.display_code}</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Created {formatDate(account.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge variant="default">
|
||||
<Users className="mr-1 h-3.5 w-3.5" />
|
||||
{account.member_count} members
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="success">{account.active_member_count} active</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{account.members.length > 0 ? (
|
||||
account.members.map((member) => renderUserRow(buildMemberRecord(account, member)))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
No members found in this account.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : !loading ? (
|
||||
<EmptyState
|
||||
icon={<Building2 className="h-8 w-8" />}
|
||||
title="No accounts to show"
|
||||
description="Create a user with a personal account or clear filters to repopulate the directory."
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(total / pageSize)}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSize={isSearching ? peoplePageSize : accountPageSize}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
{/* Role Change Modal */}
|
||||
<Modal
|
||||
isOpen={!!roleModalUser}
|
||||
onClose={() => setRoleModalUser(null)}
|
||||
title="Change Role"
|
||||
size="sm"
|
||||
footer={
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setRoleModalUser(null)}>Cancel</Button>
|
||||
<Button onClick={handleRoleChange}>Save</Button>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -343,18 +492,17 @@ export function UsersPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Move Account Modal */}
|
||||
<Modal
|
||||
isOpen={!!moveModalUser}
|
||||
onClose={() => setMoveModalUser(null)}
|
||||
title="Move User to Account"
|
||||
size="sm"
|
||||
footer={
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setMoveModalUser(null)}>Cancel</Button>
|
||||
<Button onClick={handleMoveAccount} disabled={!displayCode}>Move</Button>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -366,26 +514,25 @@ export function UsersPage() {
|
||||
type="text"
|
||||
value={displayCode}
|
||||
onChange={(e) => setDisplayCode(e.target.value)}
|
||||
placeholder="e.g. ABC-1234"
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Create User Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Create User"
|
||||
size="sm"
|
||||
footer={
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
|
||||
{createLoading ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -393,7 +540,7 @@ export function UsersPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, name: e.target.value }))}
|
||||
placeholder="Full name"
|
||||
/>
|
||||
</div>
|
||||
@@ -402,7 +549,7 @@ export function UsersPage() {
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, email: e.target.value }))}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -410,7 +557,7 @@ export function UsersPage() {
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
|
||||
<select
|
||||
value={createForm.account_mode}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, account_mode: e.target.value as 'existing' | 'personal' }))}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_mode: e.target.value as 'existing' | 'personal' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
@@ -427,7 +574,7 @@ export function UsersPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.account_display_code}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, account_display_code: e.target.value }))}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
@@ -435,7 +582,7 @@ export function UsersPage() {
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
||||
<select
|
||||
value={createForm.account_role}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
@@ -452,7 +599,7 @@ export function UsersPage() {
|
||||
type="checkbox"
|
||||
id="send-email"
|
||||
checked={createForm.send_email}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, send_email: e.target.checked }))}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, send_email: e.target.checked }))}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
<label htmlFor="send-email" className="text-sm text-muted-foreground">
|
||||
@@ -462,17 +609,16 @@ export function UsersPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Temporary Password Modal */}
|
||||
<Modal
|
||||
isOpen={!!tempPassword}
|
||||
onClose={() => setTempPassword(null)}
|
||||
title="User Created"
|
||||
size="sm"
|
||||
footer={
|
||||
footer={(
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setTempPassword(null)}>Done</Button>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
||||
@@ -481,12 +627,12 @@ export function UsersPage() {
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground font-mono">
|
||||
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
|
||||
{tempPassword}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyPassword}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Copy password"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
@@ -499,20 +645,19 @@ export function UsersPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Invite User Modal */}
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
title="Invite User"
|
||||
size="sm"
|
||||
footer={
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} loading={inviteLoading}>
|
||||
{inviteLoading ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -520,7 +665,7 @@ export function UsersPage() {
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm(f => ({ ...f, email: e.target.value }))}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -529,7 +674,7 @@ export function UsersPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={inviteForm.account_display_code}
|
||||
onChange={(e) => setInviteForm(f => ({ ...f, account_display_code: e.target.value }))}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
@@ -537,7 +682,7 @@ export function UsersPage() {
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm(f => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
|
||||
@@ -63,7 +63,7 @@ const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsP
|
||||
// Admin pages
|
||||
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
|
||||
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
|
||||
const AdminUsersPage = lazyWithRetry(() => import('@/pages/admin/UsersPage'))
|
||||
const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/UsersPage'))
|
||||
const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage'))
|
||||
const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))
|
||||
const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage'))
|
||||
@@ -222,7 +222,8 @@ export const router = sentryCreateBrowserRouter([
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: page(AdminDashboardPage) },
|
||||
{ path: 'users', element: page(AdminUsersPage) },
|
||||
{ path: 'accounts', element: page(AdminAccountsPage) },
|
||||
{ path: 'users', element: page(AdminAccountsPage) },
|
||||
{ path: 'users/:userId', element: page(AdminUserDetailPage) },
|
||||
{ path: 'invite-codes', element: page(AdminInviteCodesPage) },
|
||||
{ path: 'audit-logs', element: page(AdminAuditLogsPage) },
|
||||
|
||||
@@ -18,6 +18,60 @@ export interface ActivityEntry {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminUserListItem {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
is_super_admin: boolean
|
||||
is_active: boolean
|
||||
account_id: string | null
|
||||
account_role: string | null
|
||||
account_name: string | null
|
||||
account_display_code: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminUserListResponse {
|
||||
items: AdminUserListItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface AdminAccountMember {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
is_super_admin: boolean
|
||||
is_active: boolean
|
||||
account_role: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminAccountListItem {
|
||||
id: string
|
||||
name: string
|
||||
display_code: string
|
||||
created_at: string
|
||||
owner_id: string | null
|
||||
member_count: number
|
||||
active_member_count: number
|
||||
members: AdminAccountMember[]
|
||||
}
|
||||
|
||||
export interface AdminAccountListResponse {
|
||||
items: AdminAccountListItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
|
||||
Reference in New Issue
Block a user