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

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