Adds optional owner_email field to the Create Account modal. Superadmin can specify an existing user's email to assign as account owner at creation time. Backend 404s with a clear message if the email is unknown. Error detail now surfaces to the toast instead of a generic message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
842 lines
29 KiB
TypeScript
842 lines
29 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import {
|
|
Building2,
|
|
Check,
|
|
Copy,
|
|
ExternalLink,
|
|
Loader2,
|
|
Mail,
|
|
Plus,
|
|
Search,
|
|
Sparkles,
|
|
UserPlus,
|
|
} from 'lucide-react'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Input } from '@/components/ui/Input'
|
|
import {
|
|
DataTable,
|
|
EmptyState,
|
|
PageHeader,
|
|
Pagination,
|
|
SearchInput,
|
|
StatusBadge,
|
|
ActionMenu,
|
|
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'
|
|
import type {
|
|
AdminAccountListItem,
|
|
AdminUserListItem,
|
|
} from '@/types/admin'
|
|
|
|
function formatDate(value: string | null) {
|
|
if (!value) return 'Never'
|
|
return new Date(value).toLocaleDateString()
|
|
}
|
|
|
|
function planBadgeVariant(status: string | undefined): 'success' | 'warning' | 'destructive' | 'default' {
|
|
switch (status) {
|
|
case 'active': return 'success'
|
|
case 'trialing': return 'warning'
|
|
case 'past_due': return 'warning'
|
|
case 'canceled': return 'destructive'
|
|
default: return 'default'
|
|
}
|
|
}
|
|
|
|
export function UsersPage() {
|
|
const navigate = useNavigate()
|
|
|
|
const [accounts, setAccounts] = useState<AdminAccountListItem[]>([])
|
|
const [accountsLoading, setAccountsLoading] = useState(true)
|
|
const [accountSearch, setAccountSearch] = useState('')
|
|
const [planFilter, setPlanFilter] = useState('all')
|
|
const [statusFilter, setStatusFilter] = useState('all')
|
|
const [page, setPage] = useState(1)
|
|
const [total, setTotal] = useState(0)
|
|
const accountPageSize = 12
|
|
const [showArchived, setShowArchived] = useState(false)
|
|
|
|
const [people, setPeople] = useState<AdminUserListItem[]>([])
|
|
const [peopleLoading, setPeopleLoading] = useState(false)
|
|
const [peopleSearch, setPeopleSearch] = useState('')
|
|
const [peoplePage, setPeoplePage] = useState(1)
|
|
const [peopleTotal, setPeopleTotal] = useState(0)
|
|
const peoplePageSize = 12
|
|
|
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
const [createForm, setCreateForm] = useState({
|
|
email: '',
|
|
name: '',
|
|
account_mode: 'personal' as 'existing' | 'personal',
|
|
account_display_code: '',
|
|
account_role: 'engineer' as 'owner' | 'admin' | 'engineer' | 'viewer',
|
|
send_email: true,
|
|
})
|
|
const [createLoading, setCreateLoading] = useState(false)
|
|
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
|
const [copied, setCopied] = useState(false)
|
|
const [showInviteModal, setShowInviteModal] = useState(false)
|
|
const [inviteForm, setInviteForm] = useState({
|
|
email: '',
|
|
account_display_code: '',
|
|
role: 'engineer' as 'engineer' | 'viewer',
|
|
})
|
|
const [inviteLoading, setInviteLoading] = useState(false)
|
|
const [showCreateAccountModal, setShowCreateAccountModal] = useState(false)
|
|
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team', owner_email: '' })
|
|
const [createAccountLoading, setCreateAccountLoading] = useState(false)
|
|
|
|
const fetchAccounts = useCallback(async () => {
|
|
setAccountsLoading(true)
|
|
try {
|
|
const accountsData = await adminApi.listAccounts({
|
|
page,
|
|
size: accountPageSize,
|
|
search: accountSearch || undefined,
|
|
plan: planFilter !== 'all' ? planFilter : undefined,
|
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
include_archived: showArchived || undefined,
|
|
})
|
|
setAccounts(accountsData.items)
|
|
setTotal(accountsData.total)
|
|
} catch {
|
|
toast.error('Failed to load accounts')
|
|
} finally {
|
|
setAccountsLoading(false)
|
|
}
|
|
}, [accountPageSize, accountSearch, page, planFilter, showArchived, statusFilter])
|
|
|
|
const fetchPeople = useCallback(async () => {
|
|
if (!peopleSearch.trim()) {
|
|
setPeopleLoading(false)
|
|
setPeople([])
|
|
setPeopleTotal(0)
|
|
return
|
|
}
|
|
setPeopleLoading(true)
|
|
try {
|
|
const data = await adminApi.listUsers({
|
|
page: peoplePage,
|
|
size: peoplePageSize,
|
|
search: peopleSearch || undefined,
|
|
include_archived: showArchived || undefined,
|
|
})
|
|
setPeople(data.items)
|
|
setPeopleTotal(data.total)
|
|
} catch {
|
|
toast.error('Failed to load people search')
|
|
} finally {
|
|
setPeopleLoading(false)
|
|
}
|
|
}, [peoplePage, peoplePageSize, peopleSearch, showArchived])
|
|
|
|
useEffect(() => {
|
|
fetchAccounts()
|
|
}, [fetchAccounts])
|
|
|
|
useEffect(() => {
|
|
fetchPeople()
|
|
}, [fetchPeople])
|
|
|
|
const handleCreateUser = async () => {
|
|
if (!createForm.email || !createForm.name) return
|
|
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
|
|
toast.error('Account display code is required')
|
|
return
|
|
}
|
|
setCreateLoading(true)
|
|
try {
|
|
const result = await adminApi.createUser({
|
|
email: createForm.email,
|
|
name: createForm.name,
|
|
account_mode: createForm.account_mode,
|
|
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
|
|
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
|
|
send_email: createForm.send_email,
|
|
})
|
|
setShowCreateModal(false)
|
|
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,
|
|
})
|
|
fetchAccounts()
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'response' in err) {
|
|
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
|
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
|
|
} else {
|
|
toast.error('Failed to create user')
|
|
}
|
|
} finally {
|
|
setCreateLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleCopyPassword = async () => {
|
|
if (!tempPassword) return
|
|
await navigator.clipboard.writeText(tempPassword)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
const handleInviteUser = async () => {
|
|
if (!inviteForm.email || !inviteForm.account_display_code) return
|
|
setInviteLoading(true)
|
|
try {
|
|
const result = await adminApi.createInvite({
|
|
email: inviteForm.email,
|
|
account_display_code: inviteForm.account_display_code,
|
|
role: inviteForm.role,
|
|
})
|
|
setShowInviteModal(false)
|
|
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
|
|
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
|
|
fetchAccounts()
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'response' in err) {
|
|
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
|
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
|
|
} else {
|
|
toast.error('Failed to send invite')
|
|
}
|
|
} finally {
|
|
setInviteLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleCreateAccount = async () => {
|
|
if (!createAccountForm.name.trim()) return
|
|
setCreateAccountLoading(true)
|
|
try {
|
|
const created = await adminApi.createAccount({
|
|
name: createAccountForm.name.trim(),
|
|
plan: createAccountForm.plan,
|
|
owner_email: createAccountForm.owner_email.trim() || undefined,
|
|
})
|
|
toast.success('Account created')
|
|
setShowCreateAccountModal(false)
|
|
setCreateAccountForm({ name: '', plan: 'free', owner_email: '' })
|
|
navigate(`/admin/accounts/${created.id}`)
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'response' in err) {
|
|
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
|
toast.error(axiosErr.response?.data?.detail || 'Failed to create account')
|
|
} else {
|
|
toast.error('Failed to create account')
|
|
}
|
|
} finally {
|
|
setCreateAccountLoading(false)
|
|
}
|
|
}
|
|
|
|
const accountColumns: Column<AdminAccountListItem>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'Account',
|
|
render: (account) => (
|
|
<div className="min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(`/admin/accounts/${account.id}`)}
|
|
className="text-sm font-medium text-foreground hover:underline"
|
|
>
|
|
{account.name}
|
|
</button>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{account.display_code}
|
|
{account.owner ? ` · ${account.owner.name}` : ''}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'plan',
|
|
header: 'Plan',
|
|
render: (account) => (
|
|
<StatusBadge variant="default">
|
|
{account.subscription?.plan ?? 'free'}
|
|
</StatusBadge>
|
|
),
|
|
className: 'w-[100px]',
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
render: (account) => {
|
|
if (!account.subscription) {
|
|
return <StatusBadge variant="warning">No subscription</StatusBadge>
|
|
}
|
|
return (
|
|
<StatusBadge variant={planBadgeVariant(account.subscription.status)}>
|
|
{account.subscription.status}
|
|
</StatusBadge>
|
|
)
|
|
},
|
|
className: 'w-[120px]',
|
|
},
|
|
{
|
|
key: 'members',
|
|
header: 'Members',
|
|
render: (account) => (
|
|
<span className="text-sm text-foreground">
|
|
{account.active_member_count}
|
|
<span className="text-muted-foreground"> / {account.member_count}</span>
|
|
</span>
|
|
),
|
|
className: 'w-[100px]',
|
|
},
|
|
{
|
|
key: 'usage',
|
|
header: 'Usage',
|
|
render: (account) => (
|
|
<span className="text-sm text-muted-foreground">
|
|
{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions
|
|
</span>
|
|
),
|
|
className: 'w-[160px]',
|
|
},
|
|
{
|
|
key: 'created',
|
|
header: 'Created',
|
|
render: (account) => (
|
|
<span className="text-sm text-muted-foreground">{formatDate(account.created_at)}</span>
|
|
),
|
|
className: 'w-[100px]',
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (account) => (
|
|
<ActionMenu
|
|
items={[
|
|
{
|
|
label: 'Manage Account',
|
|
icon: <Building2 className="h-4 w-4" />,
|
|
onClick: () => navigate(`/admin/accounts/${account.id}`),
|
|
},
|
|
...(account.owner ? [{
|
|
label: 'View Owner',
|
|
icon: <ExternalLink className="h-4 w-4" />,
|
|
onClick: () => navigate(`/admin/users/${account.owner?.id}`),
|
|
}] : []),
|
|
]}
|
|
/>
|
|
),
|
|
className: 'w-[48px]',
|
|
},
|
|
]
|
|
|
|
const peopleColumns: Column<AdminUserListItem>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'Name',
|
|
render: (user) => (
|
|
<div className="min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(`/admin/users/${user.id}`)}
|
|
className="text-sm font-medium text-foreground hover:underline"
|
|
>
|
|
{user.name}
|
|
</button>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">{user.email}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'role',
|
|
header: 'Role',
|
|
render: (user) => (
|
|
<div className="flex flex-wrap gap-1">
|
|
{user.is_super_admin && <StatusBadge variant="destructive">Super Admin</StatusBadge>}
|
|
<StatusBadge variant="default">{user.role}</StatusBadge>
|
|
</div>
|
|
),
|
|
className: 'w-[140px]',
|
|
},
|
|
{
|
|
key: 'account',
|
|
header: 'Account',
|
|
render: (user) => (
|
|
<span className="text-sm text-muted-foreground">
|
|
{user.account_name || 'No account'}
|
|
{user.account_display_code && (
|
|
<span className="ml-1 text-xs opacity-60">{user.account_display_code}</span>
|
|
)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
render: (user) => (
|
|
<div className="flex gap-1">
|
|
<StatusBadge variant={user.is_active ? 'success' : 'destructive'}>
|
|
{user.is_active ? 'Active' : 'Inactive'}
|
|
</StatusBadge>
|
|
{user.deleted_at && <StatusBadge variant="warning">Archived</StatusBadge>}
|
|
</div>
|
|
),
|
|
className: 'w-[140px]',
|
|
},
|
|
{
|
|
key: 'last_login',
|
|
header: 'Last Login',
|
|
render: (user) => (
|
|
<span className="text-sm text-muted-foreground">{formatDate(user.last_login)}</span>
|
|
),
|
|
className: 'w-[100px]',
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (user) => (
|
|
<ActionMenu
|
|
items={[
|
|
{
|
|
label: 'View Detail',
|
|
icon: <ExternalLink className="h-4 w-4" />,
|
|
onClick: () => navigate(`/admin/users/${user.id}`),
|
|
},
|
|
]}
|
|
/>
|
|
),
|
|
className: 'w-[48px]',
|
|
},
|
|
]
|
|
|
|
const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize))
|
|
const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize))
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="Accounts"
|
|
description="Manage customer accounts, subscriptions, and users."
|
|
action={
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="secondary" onClick={() => setShowCreateAccountModal(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Create Account
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
|
|
<Mail className="h-4 w-4" />
|
|
Invite User
|
|
</Button>
|
|
<Button onClick={() => setShowCreateModal(true)}>
|
|
<UserPlus className="h-4 w-4" />
|
|
Create User
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<SearchInput
|
|
value={accountSearch}
|
|
onSearch={(value) => {
|
|
setAccountSearch(value)
|
|
setPage(1)
|
|
}}
|
|
placeholder="Search accounts, owners, or codes..."
|
|
className="w-full sm:max-w-sm"
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<select
|
|
value={planFilter}
|
|
onChange={(e) => {
|
|
setPlanFilter(e.target.value)
|
|
setPage(1)
|
|
}}
|
|
className={cn(
|
|
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
|
|
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="all">All plans</option>
|
|
<option value="free">Free</option>
|
|
<option value="pro">Pro</option>
|
|
<option value="team">Team</option>
|
|
</select>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => {
|
|
setStatusFilter(e.target.value)
|
|
setPage(1)
|
|
}}
|
|
className={cn(
|
|
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
|
|
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="all">All statuses</option>
|
|
<option value="active">Active</option>
|
|
<option value="trialing">Trialing</option>
|
|
<option value="past_due">Past due</option>
|
|
<option value="canceled">Canceled</option>
|
|
<option value="orphaned">Orphaned</option>
|
|
</select>
|
|
<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)
|
|
setPeoplePage(1)
|
|
}}
|
|
className="rounded border-border bg-card"
|
|
/>
|
|
Archived
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Accounts table */}
|
|
<section className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-medium text-muted-foreground">
|
|
{accountsLoading ? 'Loading...' : `${total} accounts`}
|
|
</h2>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={accountColumns}
|
|
data={accounts}
|
|
keyExtractor={(a) => a.id}
|
|
isLoading={accountsLoading}
|
|
skeletonRows={6}
|
|
emptyState={
|
|
<EmptyState
|
|
icon={<Building2 className="h-8 w-8" />}
|
|
title="No accounts found"
|
|
description="Adjust the filters or clear the search."
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<Pagination
|
|
page={page}
|
|
totalPages={accountTotalPages}
|
|
total={total}
|
|
pageSize={accountPageSize}
|
|
onPageChange={setPage}
|
|
/>
|
|
</section>
|
|
|
|
{/* Global people search */}
|
|
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-base font-semibold text-foreground">Global People Search</h2>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Find a user across all accounts by name or email.
|
|
</p>
|
|
</div>
|
|
|
|
<SearchInput
|
|
value={peopleSearch}
|
|
onSearch={(value) => {
|
|
setPeopleSearch(value)
|
|
setPeoplePage(1)
|
|
}}
|
|
placeholder="Search by name, email, or account..."
|
|
className="max-w-sm"
|
|
/>
|
|
|
|
{peopleSearch.trim() ? (
|
|
people.length > 0 ? (
|
|
<div className="space-y-3">
|
|
<DataTable
|
|
columns={peopleColumns}
|
|
data={people}
|
|
keyExtractor={(p) => p.id}
|
|
isLoading={peopleLoading}
|
|
skeletonRows={4}
|
|
emptyState={
|
|
<EmptyState
|
|
icon={<Sparkles className="h-8 w-8" />}
|
|
title="No matching people"
|
|
description="Try another name or email."
|
|
/>
|
|
}
|
|
/>
|
|
<Pagination
|
|
page={peoplePage}
|
|
totalPages={peopleTotalPages}
|
|
total={peopleTotal}
|
|
pageSize={peoplePageSize}
|
|
onPageChange={setPeoplePage}
|
|
/>
|
|
</div>
|
|
) : !peopleLoading ? (
|
|
<EmptyState
|
|
icon={<Sparkles className="h-8 w-8" />}
|
|
title="No matching people"
|
|
description="Try another name or email."
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Searching...
|
|
</div>
|
|
)
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">Type a name or email to search.</p>
|
|
)}
|
|
</section>
|
|
|
|
{/* Create Account modal */}
|
|
<Modal
|
|
isOpen={showCreateAccountModal}
|
|
onClose={() => setShowCreateAccountModal(false)}
|
|
title="Create Account"
|
|
size="sm"
|
|
footer={(
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="secondary" onClick={() => setShowCreateAccountModal(false)}>Cancel</Button>
|
|
<Button onClick={handleCreateAccount} disabled={!createAccountForm.name.trim()} loading={createAccountLoading}>
|
|
{createAccountLoading ? 'Creating...' : 'Create Account'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Account Name</label>
|
|
<Input
|
|
value={createAccountForm.name}
|
|
onChange={(e) => setCreateAccountForm((form) => ({ ...form, name: e.target.value }))}
|
|
placeholder="Acme MSP"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Initial Plan</label>
|
|
<select
|
|
value={createAccountForm.plan}
|
|
onChange={(e) => setCreateAccountForm((form) => ({ ...form, plan: e.target.value as 'free' | 'pro' | 'team' }))}
|
|
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'
|
|
)}
|
|
>
|
|
<option value="free">Free</option>
|
|
<option value="pro">Pro</option>
|
|
<option value="team">Team</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">
|
|
Owner Email <span className="text-muted-foreground font-normal">(optional)</span>
|
|
</label>
|
|
<Input
|
|
type="email"
|
|
value={createAccountForm.owner_email}
|
|
onChange={(e) => setCreateAccountForm((form) => ({ ...form, owner_email: e.target.value }))}
|
|
placeholder="owner@example.com"
|
|
/>
|
|
<p className="mt-1 text-xs text-muted-foreground">Must be an existing user.</p>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Create User modal */}
|
|
<Modal
|
|
isOpen={showCreateModal}
|
|
onClose={() => setShowCreateModal(false)}
|
|
title="Create User"
|
|
size="sm"
|
|
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>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
|
<Input
|
|
type="text"
|
|
value={createForm.name}
|
|
onChange={(e) => setCreateForm((form) => ({ ...form, name: e.target.value }))}
|
|
placeholder="Full name"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
|
<Input
|
|
type="email"
|
|
value={createForm.email}
|
|
onChange={(e) => setCreateForm((form) => ({ ...form, email: e.target.value }))}
|
|
placeholder="user@example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
|
|
<select
|
|
value={createForm.account_mode}
|
|
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'
|
|
)}
|
|
>
|
|
<option value="personal">Personal (new account)</option>
|
|
<option value="existing">Join existing account</option>
|
|
</select>
|
|
</div>
|
|
{createForm.account_mode === 'existing' && (
|
|
<>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
|
<Input
|
|
type="text"
|
|
value={createForm.account_display_code}
|
|
onChange={(e) => setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))}
|
|
placeholder="e.g. ABC12345"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
|
<select
|
|
value={createForm.account_role}
|
|
onChange={(e) => setCreateForm((form) => ({ ...form, account_role: e.target.value as 'owner' | 'admin' | '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'
|
|
)}
|
|
>
|
|
<option value="owner">Owner</option>
|
|
<option value="admin">Admin</option>
|
|
<option value="engineer">Engineer</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="send-email"
|
|
checked={createForm.send_email}
|
|
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">
|
|
Send welcome email with temporary password
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Temp password modal */}
|
|
<Modal
|
|
isOpen={!!tempPassword}
|
|
onClose={() => setTempPassword(null)}
|
|
title="User Created"
|
|
size="sm"
|
|
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">
|
|
This password will not be shown again. Copy it now.
|
|
</div>
|
|
<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 font-mono text-sm text-foreground">
|
|
{tempPassword}
|
|
</code>
|
|
<button
|
|
onClick={handleCopyPassword}
|
|
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-elevated hover:text-foreground"
|
|
title="Copy password"
|
|
>
|
|
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
The user will be required to change this password on first login.
|
|
</p>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Invite User modal */}
|
|
<Modal
|
|
isOpen={showInviteModal}
|
|
onClose={() => setShowInviteModal(false)}
|
|
title="Invite User"
|
|
size="sm"
|
|
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>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
|
<Input
|
|
type="email"
|
|
value={inviteForm.email}
|
|
onChange={(e) => setInviteForm((form) => ({ ...form, email: e.target.value }))}
|
|
placeholder="user@example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
|
<Input
|
|
type="text"
|
|
value={inviteForm.account_display_code}
|
|
onChange={(e) => setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))}
|
|
placeholder="e.g. ABC12345"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
|
|
<select
|
|
value={inviteForm.role}
|
|
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'
|
|
)}
|
|
>
|
|
<option value="engineer">Engineer</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default UsersPage
|