feat: reorganize admin panel around accounts

This commit is contained in:
chihlasm
2026-04-02 03:46:11 +00:00
parent f54d7ecd78
commit 70242ad037
10 changed files with 624 additions and 188 deletions

View File

@@ -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.admin_database import get_admin_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_admin_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:

View File

@@ -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):

View File

@@ -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(

View File

@@ -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) =>

View File

@@ -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 },

View File

@@ -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 },

View File

@@ -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" />

View File

@@ -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'

View File

@@ -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) },

View File

@@ -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