feat: reorganize admin panel around accounts

This commit is contained in:
chihlasm
2026-04-02 03:46:11 +00:00
parent b8189a1999
commit bfcb8c52d3
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 uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.database import get_db 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.account_invite import AccountInvite
from app.models.tree import Tree from app.models.tree import Tree
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate 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.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
from app.schemas.user_detail import ( from app.schemas.user_detail import (
UserDetailResponse, AccountSummary, SubscriptionSummary, UserDetailResponse, AccountSummary, SubscriptionSummary,
@@ -35,10 +47,13 @@ from app.api.deps import require_admin
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users", response_model=list[UserResponse]) @router.get("/users", response_model=AdminUserListResponse)
async def list_users( async def list_users(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)], 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), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100), limit: int = Query(100, ge=1, le=100),
is_active: Optional[bool] = Query(None, description="Filter by active status"), 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"), account_id: Optional[UUID] = Query(None, description="Filter by account"),
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"), include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
): ):
"""List all users (super admin only).""" """List users for super admin global people search."""
query = select(User) 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: if not include_archived:
query = query.where(User.deleted_at.is_(None)) query = query.where(User.deleted_at.is_(None))
count_query = count_query.where(User.deleted_at.is_(None))
if is_active is not None: if is_active is not None:
query = query.where(User.is_active == is_active) query = query.where(User.is_active == is_active)
count_query = count_query.where(User.is_active == is_active)
if role: if role:
query = query.where(User.role == role) query = query.where(User.role == role)
count_query = count_query.where(User.role == role)
if account_id: if account_id:
query = query.where(User.account_id == 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) result = await db.execute(query)
users = result.scalars().all() rows = result.all()
return users
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: def _generate_display_code() -> str:

View File

@@ -28,6 +28,62 @@ class ActivityEntry(BaseModel):
from_attributes = True 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 --- # --- Audit Logs ---
class AuditLogEntry(BaseModel): class AuditLogEntry(BaseModel):

View File

@@ -19,8 +19,38 @@ class TestAdminEndpoints:
"/api/v1/admin/users", headers=admin_auth_headers "/api/v1/admin/users", headers=admin_auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
users = response.json() payload = response.json()
assert len(users) >= 2 # admin + test_user 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 @pytest.mark.asyncio
async def test_list_users_as_non_admin( async def test_list_users_as_non_admin(

View File

@@ -2,6 +2,8 @@ import api from './client'
import type { import type {
DashboardMetrics, DashboardMetrics,
ActivityEntry, ActivityEntry,
AdminUserListResponse,
AdminAccountListResponse,
AuditLogListResponse, AuditLogListResponse,
PlanLimitConfig, PlanLimitConfig,
AccountOverrideResponse, AccountOverrideResponse,
@@ -78,7 +80,9 @@ export const adminApi = {
createUser: (data: AdminUserCreate) => createUser: (data: AdminUserCreate) =>
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data), api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
listUsers: (params?: Record<string, unknown>) => 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) => getUser: (id: string) =>
api.get(`/admin/users/${id}`).then(r => r.data), api.get(`/admin/users/${id}`).then(r => r.data),
updateUserRole: (id: string, role: string) => updateUserRole: (id: string, role: string) =>

View File

@@ -1,7 +1,7 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { import {
LayoutDashboard, LayoutDashboard,
Users, Building2,
Ticket, Ticket,
FileText, FileText,
Gauge, Gauge,
@@ -17,7 +17,7 @@ import { cn } from '@/lib/utils'
const navItems = [ const navItems = [
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true }, { 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/invite-codes', label: 'Invite Codes', icon: Ticket },
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText }, { path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge }, { path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' 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 { cn } from '@/lib/utils'
import { PageHeader } from '@/components/admin' import { PageHeader } from '@/components/admin'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
@@ -43,7 +43,7 @@ export function DashboardPage() {
}, []) }, [])
const quickLinks = [ 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/plan-limits', label: 'Plan Limits', icon: TrendingUp },
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity }, { to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity }, { to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },

View File

@@ -177,7 +177,7 @@ export function UserDetailPage() {
try { try {
await adminApi.hardDeleteUser(userId) await adminApi.hardDeleteUser(userId)
toast.success('User permanently deleted') toast.success('User permanently deleted')
navigate('/admin/users') navigate('/admin/accounts')
} catch (err: unknown) { } catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) { if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } } const axiosErr = err as { response?: { data?: { detail?: string } } }
@@ -207,8 +207,8 @@ export function UserDetailPage() {
title="User not found" title="User not found"
description="This user may have been removed or is unavailable." description="This user may have been removed or is unavailable."
action={( action={(
<Button variant="secondary" onClick={() => navigate('/admin/users')}> <Button variant="secondary" onClick={() => navigate('/admin/accounts')}>
Back to Users Back to Accounts
</Button> </Button>
)} )}
/> />
@@ -223,7 +223,7 @@ export function UserDetailPage() {
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <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" className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />

View File

@@ -1,48 +1,64 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom' 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 { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin' import { Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal' import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AdminAccountListItem, AdminAccountMember, AdminUserListItem } from '@/types/admin'
interface AdminUser { type UserRecord = AdminUserListItem | (AdminAccountMember & {
id: string account_id: string
email: string account_name: string
name: string account_display_code: string
role: string })
is_super_admin: boolean
is_active: boolean function formatDate(value: string | null) {
account_id: string | null if (!value) return 'Never'
account_role: string | null return new Date(value).toLocaleDateString()
created_at: string }
last_login: string | null
deleted_at: string | null 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() { export function UsersPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [users, setUsers] = useState<AdminUser[]>([]) const [accounts, setAccounts] = useState<AdminAccountListItem[]>([])
const [people, setPeople] = useState<AdminUserListItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const pageSize = 20 const accountPageSize = 8
const peoplePageSize = 20
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
// Role change modal const [roleModalUser, setRoleModalUser] = useState<UserRecord | null>(null)
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
const [newRole, setNewRole] = useState('') const [newRole, setNewRole] = useState('')
const [moveModalUser, setMoveModalUser] = useState<UserRecord | null>(null)
// Move account modal
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
const [displayCode, setDisplayCode] = useState('') const [displayCode, setDisplayCode] = useState('')
// Create user modal
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
email: '', email: '',
@@ -53,30 +69,60 @@ export function UsersPage() {
send_email: true, send_email: true,
}) })
const [createLoading, setCreateLoading] = useState(false) const [createLoading, setCreateLoading] = useState(false)
// Temp password display modal
const [tempPassword, setTempPassword] = useState<string | null>(null) const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
// Invite user modal
const [showInviteModal, setShowInviteModal] = useState(false) 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 [inviteLoading, setInviteLoading] = useState(false)
const fetchUsers = useCallback(async () => { const fetchAccounts = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined, include_archived: showArchived || undefined }) const data = await adminApi.listAccounts({
setUsers(data.items || data) page,
setTotal(data.total || (data.items ? data.items.length : data.length)) size: accountPageSize,
include_archived: showArchived || undefined,
})
setAccounts(data.items)
setPeople([])
setTotal(data.total)
} catch { } catch {
toast.error('Failed to load users') toast.error('Failed to load accounts')
} finally { } finally {
setLoading(false) 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 () => { const handleRoleChange = async () => {
if (!roleModalUser || !newRole) return if (!roleModalUser || !newRole) return
@@ -84,13 +130,17 @@ export function UsersPage() {
await adminApi.updateUserRole(roleModalUser.id, newRole) await adminApi.updateUserRole(roleModalUser.id, newRole)
toast.success('Role updated') toast.success('Role updated')
setRoleModalUser(null) setRoleModalUser(null)
fetchUsers() if (search.trim()) {
fetchPeople()
} else {
fetchAccounts()
}
} catch { } catch {
toast.error('Failed to update role') toast.error('Failed to update role')
} }
} }
const handleToggleActive = async (user: AdminUser) => { const handleToggleActive = async (user: UserRecord) => {
try { try {
if (user.is_active) { if (user.is_active) {
await adminApi.deactivateUser(user.id) await adminApi.deactivateUser(user.id)
@@ -99,7 +149,11 @@ export function UsersPage() {
await adminApi.activateUser(user.id) await adminApi.activateUser(user.id)
toast.success('User activated') toast.success('User activated')
} }
fetchUsers() if (search.trim()) {
fetchPeople()
} else {
fetchAccounts()
}
} catch { } catch {
toast.error('Failed to update user status') toast.error('Failed to update user status')
} }
@@ -112,7 +166,11 @@ export function UsersPage() {
toast.success('User moved to account') toast.success('User moved to account')
setMoveModalUser(null) setMoveModalUser(null)
setDisplayCode('') setDisplayCode('')
fetchUsers() if (search.trim()) {
fetchPeople()
} else {
fetchAccounts()
}
} catch { } catch {
toast.error('Failed to move user') toast.error('Failed to move user')
} }
@@ -138,8 +196,19 @@ export function UsersPage() {
setTempPassword(result.temporary_password) setTempPassword(result.temporary_password)
setCopied(false) setCopied(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created') 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 }) setCreateForm({
fetchUsers() email: '',
name: '',
account_mode: 'personal',
account_display_code: '',
account_role: 'engineer',
send_email: true,
})
if (search.trim()) {
fetchPeople()
} else {
fetchAccounts()
}
} catch (err: unknown) { } catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) { if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } } 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', label: 'View Detail',
header: 'Name', icon: <ExternalLink className="h-4 w-4" />,
sortable: true, onClick: () => navigate(`/admin/users/${user.id}`),
render: (u) => (
<div>
<div className="font-medium text-foreground">{u.name}</div>
<div className="text-xs text-muted-foreground">{u.email}</div>
</div>
),
}, },
{ {
key: 'role', label: 'Change Role',
header: 'Role', icon: <Shield className="h-4 w-4" />,
render: (u) => ( onClick: () => {
<div className="flex items-center gap-2"> setRoleModalUser(user)
<span className="text-sm">{u.role}</span> setNewRole(user.role)
{u.is_super_admin && ( },
<StatusBadge variant="destructive">Super Admin</StatusBadge>
)}
</div>
),
}, },
{ {
key: 'status', label: user.is_active ? 'Deactivate' : 'Activate',
header: 'Status', icon: user.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
render: (u) => ( onClick: () => handleToggleActive(user),
<div className="flex items-center gap-1"> destructive: user.is_active,
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}> },
{u.is_active ? 'Active' : 'Inactive'} {
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> </StatusBadge>
{u.deleted_at && ( {user.deleted_at && <StatusBadge variant="warning">Archived</StatusBadge>}
<StatusBadge variant="warning">Archived</StatusBadge>
)}
</div> </div>
), <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>
key: 'created_at', <span>Account: {user.account_name || 'No account'}</span>
header: 'Joined', {user.account_display_code && <span>Code: {user.account_display_code}</span>}
sortable: true, <span>Last login: {formatDate(user.last_login)}</span>
render: (u) => ( </div>
<span className="text-sm text-muted-foreground"> </div>
{new Date(u.created_at).toLocaleDateString()} <div className="flex items-center justify-end">
</span> <ActionMenu items={userActions(user)} />
), </div>
}, </div>
{ )
key: 'actions',
header: '', const isSearching = Boolean(search.trim())
className: 'w-12', const totalPages = Math.max(1, Math.ceil(total / (isSearching ? peoplePageSize : accountPageSize)))
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('') },
},
]} />
),
},
]
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<PageHeader title="Users" description="Manage platform users and roles" /> <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"> <div className="flex items-center gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(true)}> <Button variant="secondary" onClick={() => setShowInviteModal(true)}>
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
@@ -279,51 +333,146 @@ export function UsersPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="rounded-2xl border border-border bg-card p-4">
<SearchInput <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
value={search} <div>
onSearch={(v) => { setSearch(v); setPage(1) }} <h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">
placeholder="Search by name or email..." Global People Search
className="max-w-sm" </h2>
/> <p className="mt-1 text-sm text-muted-foreground">
<label className="flex items-center gap-2 text-sm text-muted-foreground"> Search any name or email across every account. Leave it blank to browse accounts and their members.
<input </p>
type="checkbox" </div>
checked={showArchived} <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
onChange={(e) => { setShowArchived(e.target.checked); setPage(1) }} <SearchInput
className="rounded border-border bg-card" value={search}
/> onSearch={(value) => {
Show archived setSearch(value)
</label> 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> </div>
<DataTable {isSearching ? (
columns={columns} <section className="space-y-4">
data={users} <div className="flex items-center gap-3">
keyExtractor={(u) => u.id} <div className="rounded-xl bg-accent p-2 text-muted-foreground">
isLoading={loading} <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 <Pagination
page={page} page={page}
totalPages={Math.ceil(total / pageSize)} totalPages={totalPages}
total={total} total={total}
pageSize={pageSize} pageSize={isSearching ? peoplePageSize : accountPageSize}
onPageChange={setPage} onPageChange={setPage}
/> />
{/* Role Change Modal */}
<Modal <Modal
isOpen={!!roleModalUser} isOpen={!!roleModalUser}
onClose={() => setRoleModalUser(null)} onClose={() => setRoleModalUser(null)}
title="Change Role" title="Change Role"
size="sm" size="sm"
footer={ footer={(
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setRoleModalUser(null)}>Cancel</Button> <Button variant="secondary" onClick={() => setRoleModalUser(null)}>Cancel</Button>
<Button onClick={handleRoleChange}>Save</Button> <Button onClick={handleRoleChange}>Save</Button>
</div> </div>
} )}
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -343,18 +492,17 @@ export function UsersPage() {
</div> </div>
</Modal> </Modal>
{/* Move Account Modal */}
<Modal <Modal
isOpen={!!moveModalUser} isOpen={!!moveModalUser}
onClose={() => setMoveModalUser(null)} onClose={() => setMoveModalUser(null)}
title="Move User to Account" title="Move User to Account"
size="sm" size="sm"
footer={ footer={(
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setMoveModalUser(null)}>Cancel</Button> <Button variant="secondary" onClick={() => setMoveModalUser(null)}>Cancel</Button>
<Button onClick={handleMoveAccount} disabled={!displayCode}>Move</Button> <Button onClick={handleMoveAccount} disabled={!displayCode}>Move</Button>
</div> </div>
} )}
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -366,26 +514,25 @@ export function UsersPage() {
type="text" type="text"
value={displayCode} value={displayCode}
onChange={(e) => setDisplayCode(e.target.value)} onChange={(e) => setDisplayCode(e.target.value)}
placeholder="e.g. ABC-1234" placeholder="e.g. ABC12345"
/> />
</div> </div>
</div> </div>
</Modal> </Modal>
{/* Create User Modal */}
<Modal <Modal
isOpen={showCreateModal} isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)} onClose={() => setShowCreateModal(false)}
title="Create User" title="Create User"
size="sm" size="sm"
footer={ footer={(
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>Cancel</Button> <Button variant="secondary" onClick={() => setShowCreateModal(false)}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}> <Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
{createLoading ? 'Creating...' : 'Create User'} {createLoading ? 'Creating...' : 'Create User'}
</Button> </Button>
</div> </div>
} )}
> >
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -393,7 +540,7 @@ export function UsersPage() {
<Input <Input
type="text" type="text"
value={createForm.name} value={createForm.name}
onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))} onChange={(e) => setCreateForm((form) => ({ ...form, name: e.target.value }))}
placeholder="Full name" placeholder="Full name"
/> />
</div> </div>
@@ -402,7 +549,7 @@ export function UsersPage() {
<Input <Input
type="email" type="email"
value={createForm.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" placeholder="user@example.com"
/> />
</div> </div>
@@ -410,7 +557,7 @@ export function UsersPage() {
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label> <label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
<select <select
value={createForm.account_mode} 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( className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', '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' 'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
@@ -427,7 +574,7 @@ export function UsersPage() {
<Input <Input
type="text" type="text"
value={createForm.account_display_code} 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" placeholder="e.g. ABC12345"
/> />
</div> </div>
@@ -435,7 +582,7 @@ export function UsersPage() {
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label> <label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
<select <select
value={createForm.account_role} 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( className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', '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' 'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
@@ -452,7 +599,7 @@ export function UsersPage() {
type="checkbox" type="checkbox"
id="send-email" id="send-email"
checked={createForm.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" className="rounded border-border bg-card"
/> />
<label htmlFor="send-email" className="text-sm text-muted-foreground"> <label htmlFor="send-email" className="text-sm text-muted-foreground">
@@ -462,17 +609,16 @@ export function UsersPage() {
</div> </div>
</Modal> </Modal>
{/* Temporary Password Modal */}
<Modal <Modal
isOpen={!!tempPassword} isOpen={!!tempPassword}
onClose={() => setTempPassword(null)} onClose={() => setTempPassword(null)}
title="User Created" title="User Created"
size="sm" size="sm"
footer={ footer={(
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={() => setTempPassword(null)}>Done</Button> <Button onClick={() => setTempPassword(null)}>Done</Button>
</div> </div>
} )}
> >
<div className="space-y-4"> <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"> <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> <div>
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label> <label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
<div className="flex items-center gap-2"> <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} {tempPassword}
</code> </code>
<button <button
onClick={handleCopyPassword} 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" title="Copy password"
> >
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />} {copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
@@ -499,20 +645,19 @@ export function UsersPage() {
</div> </div>
</Modal> </Modal>
{/* Invite User Modal */}
<Modal <Modal
isOpen={showInviteModal} isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)} onClose={() => setShowInviteModal(false)}
title="Invite User" title="Invite User"
size="sm" size="sm"
footer={ footer={(
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button> <Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} loading={inviteLoading}> <Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} loading={inviteLoading}>
{inviteLoading ? 'Sending...' : 'Send Invite'} {inviteLoading ? 'Sending...' : 'Send Invite'}
</Button> </Button>
</div> </div>
} )}
> >
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -520,7 +665,7 @@ export function UsersPage() {
<Input <Input
type="email" type="email"
value={inviteForm.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" placeholder="user@example.com"
/> />
</div> </div>
@@ -529,7 +674,7 @@ export function UsersPage() {
<Input <Input
type="text" type="text"
value={inviteForm.account_display_code} 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" placeholder="e.g. ABC12345"
/> />
</div> </div>
@@ -537,7 +682,7 @@ export function UsersPage() {
<label className="mb-1 block text-sm font-medium text-foreground">Role</label> <label className="mb-1 block text-sm font-medium text-foreground">Role</label>
<select <select
value={inviteForm.role} 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( className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', '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' '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 // Admin pages
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout')) const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage')) 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 AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage'))
const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage')) const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))
const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage')) const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage'))
@@ -222,7 +222,8 @@ export const router = sentryCreateBrowserRouter([
), ),
children: [ children: [
{ index: true, element: page(AdminDashboardPage) }, { 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: 'users/:userId', element: page(AdminUserDetailPage) },
{ path: 'invite-codes', element: page(AdminInviteCodesPage) }, { path: 'invite-codes', element: page(AdminInviteCodesPage) },
{ path: 'audit-logs', element: page(AdminAuditLogsPage) }, { path: 'audit-logs', element: page(AdminAuditLogsPage) },

View File

@@ -18,6 +18,60 @@ export interface ActivityEntry {
created_at: string 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 { export interface AuditLogEntry {
id: string id: string
user_id: string user_id: string