- Migration 030: add email, assigned_plan, trial_duration_days, email_sent_at
to invite_codes with CHECK constraints
- Resend email integration (graceful degradation when API key not set)
- Invite codes now support plan assignment (free/pro/team) and trial duration (1-90 days)
- Registration applies invite code plan/trial to new subscription
- Auto-downgrade expired trials on authenticated access
- Enriched GET /admin/users/{id} with account, subscription, sessions, audit logs
- New endpoints: PUT /admin/users/{id}/subscription/plan and extend-trial
- Frontend: enhanced invite codes page with email, plan, trial fields
- Frontend: new user detail page at /admin/users/:userId
- Fixed API path drift: /invite-codes -> /invites
- 11 new backend tests, 416 total passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
286 lines
8.5 KiB
TypeScript
286 lines
8.5 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink } from 'lucide-react'
|
|
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
|
|
import type { Column } from '@/components/admin'
|
|
import { Modal } from '@/components/common/Modal'
|
|
import { adminApi } from '@/api/admin'
|
|
import { toast } from '@/lib/toast'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
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
|
|
}
|
|
|
|
export function UsersPage() {
|
|
const navigate = useNavigate()
|
|
const [users, setUsers] = useState<AdminUser[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [search, setSearch] = useState('')
|
|
const [page, setPage] = useState(1)
|
|
const [total, setTotal] = useState(0)
|
|
const pageSize = 20
|
|
|
|
// Role change modal
|
|
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
|
|
const [newRole, setNewRole] = useState('')
|
|
|
|
// Move account modal
|
|
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
|
|
const [displayCode, setDisplayCode] = useState('')
|
|
|
|
const fetchUsers = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined })
|
|
setUsers(data.items || data)
|
|
setTotal(data.total || (data.items ? data.items.length : data.length))
|
|
} catch {
|
|
toast.error('Failed to load users')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [page, search])
|
|
|
|
useEffect(() => { fetchUsers() }, [fetchUsers])
|
|
|
|
const handleRoleChange = async () => {
|
|
if (!roleModalUser || !newRole) return
|
|
try {
|
|
await adminApi.updateUserRole(roleModalUser.id, newRole)
|
|
toast.success('Role updated')
|
|
setRoleModalUser(null)
|
|
fetchUsers()
|
|
} catch {
|
|
toast.error('Failed to update role')
|
|
}
|
|
}
|
|
|
|
const handleToggleActive = async (user: AdminUser) => {
|
|
try {
|
|
if (user.is_active) {
|
|
await adminApi.deactivateUser(user.id)
|
|
toast.success('User deactivated')
|
|
} else {
|
|
await adminApi.activateUser(user.id)
|
|
toast.success('User activated')
|
|
}
|
|
fetchUsers()
|
|
} catch {
|
|
toast.error('Failed to update user status')
|
|
}
|
|
}
|
|
|
|
const handleMoveAccount = async () => {
|
|
if (!moveModalUser || !displayCode) return
|
|
try {
|
|
await adminApi.moveUserAccount(moveModalUser.id, displayCode)
|
|
toast.success('User moved to account')
|
|
setMoveModalUser(null)
|
|
setDisplayCode('')
|
|
fetchUsers()
|
|
} catch {
|
|
toast.error('Failed to move user')
|
|
}
|
|
}
|
|
|
|
const columns: Column<AdminUser>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'Name',
|
|
sortable: true,
|
|
render: (u) => (
|
|
<div>
|
|
<div className="font-medium text-white">{u.name}</div>
|
|
<div className="text-xs text-white/40">{u.email}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
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>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
render: (u) => (
|
|
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
|
|
{u.is_active ? 'Active' : 'Inactive'}
|
|
</StatusBadge>
|
|
),
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'Joined',
|
|
sortable: true,
|
|
render: (u) => (
|
|
<span className="text-sm text-white/40">
|
|
{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('') },
|
|
},
|
|
]} />
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader title="Users" description="Manage platform users and roles" />
|
|
|
|
<SearchInput
|
|
value={search}
|
|
onSearch={(v) => { setSearch(v); setPage(1) }}
|
|
placeholder="Search by name or email..."
|
|
className="max-w-sm"
|
|
/>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={users}
|
|
keyExtractor={(u) => u.id}
|
|
isLoading={loading}
|
|
/>
|
|
|
|
<Pagination
|
|
page={page}
|
|
totalPages={Math.ceil(total / pageSize)}
|
|
total={total}
|
|
pageSize={pageSize}
|
|
onPageChange={setPage}
|
|
/>
|
|
|
|
{/* Role Change Modal */}
|
|
<Modal
|
|
isOpen={!!roleModalUser}
|
|
onClose={() => setRoleModalUser(null)}
|
|
title="Change Role"
|
|
size="sm"
|
|
footer={
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setRoleModalUser(null)}
|
|
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleRoleChange}
|
|
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-white/70">
|
|
Changing role for <span className="font-medium text-white">{roleModalUser?.name}</span>
|
|
</p>
|
|
<select
|
|
value={newRole}
|
|
onChange={(e) => setNewRole(e.target.value)}
|
|
className={cn(
|
|
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
|
'focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
|
|
)}
|
|
>
|
|
<option value="engineer">Engineer</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Move Account Modal */}
|
|
<Modal
|
|
isOpen={!!moveModalUser}
|
|
onClose={() => setMoveModalUser(null)}
|
|
title="Move User to Account"
|
|
size="sm"
|
|
footer={
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setMoveModalUser(null)}
|
|
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleMoveAccount}
|
|
disabled={!displayCode}
|
|
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
|
|
>
|
|
Move
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-white/70">
|
|
Moving <span className="font-medium text-white">{moveModalUser?.name}</span> to a new account.
|
|
</p>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-white">Account Display Code</label>
|
|
<input
|
|
type="text"
|
|
value={displayCode}
|
|
onChange={(e) => setDisplayCode(e.target.value)}
|
|
placeholder="e.g. ABC-1234"
|
|
className={cn(
|
|
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
|
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default UsersPage
|