From a2749104f4bd7c2c191ab893896c02f23505ee4a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 2 Apr 2026 06:24:26 +0000 Subject: [PATCH] refactor: design critique fixes for account pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin accounts: replace dense card grid with compact DataTable - Account settings: remove redundant hero card, stat grid, header pills - Fix bg-accent (orange) misuse on decorative elements across 7 files - Add ConfirmButton for destructive actions (deactivate, remove member) - Replace single-field modals with inline editing (plan, trial) - Add contextual help: display code tooltip, improved empty states - Non-owner aside explanation for hidden owner-only sections - Admin sidebar: group 11 items into 5 labeled sections - Rename UsersPage.tsx → AccountsPage.tsx to match route - Fix border radius consistency, hide zero-count badges Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/admin/ActionMenu.tsx | 4 +- .../src/components/admin/AdminSidebar.tsx | 103 +- frontend/src/components/admin/DataTable.tsx | 6 +- frontend/src/components/admin/Pagination.tsx | 6 +- frontend/src/components/admin/StatusBadge.tsx | 18 +- .../src/components/common/ConfirmButton.tsx | 69 + frontend/src/pages/AccountSettingsPage.tsx | 136 +- .../src/pages/admin/AccountDetailPage.tsx | 166 +-- frontend/src/pages/admin/AccountsPage.tsx | 821 ++++++++++++ frontend/src/pages/admin/UsersPage.tsx | 1108 ----------------- frontend/src/router.tsx | 2 +- 11 files changed, 1102 insertions(+), 1337 deletions(-) create mode 100644 frontend/src/components/common/ConfirmButton.tsx create mode 100644 frontend/src/pages/admin/AccountsPage.tsx delete mode 100644 frontend/src/pages/admin/UsersPage.tsx diff --git a/frontend/src/components/admin/ActionMenu.tsx b/frontend/src/components/admin/ActionMenu.tsx index 6cabd654..978f3638 100644 --- a/frontend/src/components/admin/ActionMenu.tsx +++ b/frontend/src/components/admin/ActionMenu.tsx @@ -54,7 +54,7 @@ export function ActionMenu({ items }: ActionMenuProps) { onClick={() => setOpen(!open)} className={cn( 'rounded-md p-1.5 text-muted-foreground transition-colors', - 'hover:bg-accent hover:text-foreground' + 'hover:bg-elevated hover:text-foreground' )} > @@ -81,7 +81,7 @@ export function ActionMenu({ items }: ActionMenuProps) { 'disabled:opacity-50 disabled:pointer-events-none', item.destructive ? 'text-red-400 hover:bg-red-400/10' - : 'text-muted-foreground hover:bg-accent' + : 'text-muted-foreground hover:bg-elevated' )} > {item.icon} diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index d67c2e72..fd6827b4 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -15,18 +15,54 @@ import { } from 'lucide-react' import { cn } from '@/lib/utils' -const navItems = [ - { path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true }, - { 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 }, - { path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft }, - { path: '/admin/settings', label: 'Settings', icon: Settings }, - { path: '/admin/categories', label: 'Categories', icon: FolderTree }, - { path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList }, - { path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText }, - { path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid }, +interface NavItem { + path: string + label: string + icon: typeof LayoutDashboard + end?: boolean +} + +interface NavSection { + label?: string + items: NavItem[] +} + +const navSections: NavSection[] = [ + { + items: [ + { path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true }, + { path: '/admin/accounts', label: 'Accounts', icon: Building2 }, + { path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket }, + ], + }, + { + label: 'Platform', + items: [ + { path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge }, + { path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft }, + { path: '/admin/settings', label: 'Settings', icon: Settings }, + ], + }, + { + label: 'Content', + items: [ + { path: '/admin/categories', label: 'Categories', icon: FolderTree }, + { path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid }, + ], + }, + { + label: 'Feedback', + items: [ + { path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList }, + { path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText }, + ], + }, + { + label: 'Audit', + items: [ + { path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText }, + ], + }, ] interface AdminSidebarProps { @@ -47,22 +83,33 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {

Admin Panel

-
@@ -71,7 +118,7 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) { onClick={onNavigate} className={cn( 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium', - 'text-muted-foreground hover:bg-accent hover:text-foreground' + 'text-muted-foreground hover:bg-elevated hover:text-foreground' )} > diff --git a/frontend/src/components/admin/DataTable.tsx b/frontend/src/components/admin/DataTable.tsx index 7dc528ee..4386bcc5 100644 --- a/frontend/src/components/admin/DataTable.tsx +++ b/frontend/src/components/admin/DataTable.tsx @@ -53,7 +53,7 @@ export function DataTable({
- + {columns.map((col) => ( {columns.map((col) => ( ))} @@ -107,7 +107,7 @@ export function DataTable({ data.map((item) => ( {columns.map((col) => (
({
-
+
diff --git a/frontend/src/components/admin/Pagination.tsx b/frontend/src/components/admin/Pagination.tsx index 1e967ed8..dacd6f3f 100644 --- a/frontend/src/components/admin/Pagination.tsx +++ b/frontend/src/components/admin/Pagination.tsx @@ -43,7 +43,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }: @@ -59,7 +59,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }: 'px-2', p === page ? 'bg-primary text-white' - : 'text-muted-foreground hover:bg-accent hover:text-foreground' + : 'text-muted-foreground hover:bg-elevated hover:text-foreground' )} > {p} @@ -69,7 +69,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }: diff --git a/frontend/src/components/admin/StatusBadge.tsx b/frontend/src/components/admin/StatusBadge.tsx index 0f2e4b54..4a7b6ff3 100644 --- a/frontend/src/components/admin/StatusBadge.tsx +++ b/frontend/src/components/admin/StatusBadge.tsx @@ -6,22 +6,26 @@ interface StatusBadgeProps { variant?: BadgeVariant children: React.ReactNode className?: string + title?: string } const variantClasses: Record = { success: 'bg-emerald-400/10 text-emerald-400', destructive: 'bg-red-400/10 text-red-400', warning: 'bg-yellow-400/10 text-yellow-400', - default: 'bg-accent text-muted-foreground', + default: 'bg-muted text-muted-foreground', } -export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) { +export function StatusBadge({ variant = 'default', children, className, title }: StatusBadgeProps) { return ( - + {children} ) diff --git a/frontend/src/components/common/ConfirmButton.tsx b/frontend/src/components/common/ConfirmButton.tsx new file mode 100644 index 00000000..0fda507e --- /dev/null +++ b/frontend/src/components/common/ConfirmButton.tsx @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { cn } from '@/lib/utils' + +interface ConfirmButtonProps { + onConfirm: () => void + children: React.ReactNode + confirmLabel?: string + className?: string + confirmClassName?: string + timeoutMs?: number + 'aria-label'?: string +} + +/** + * Two-click inline confirm button. + * First click arms the button (shows confirm state). + * Second click executes the action. + * Auto-resets after timeoutMs (default 3000ms). + */ +export function ConfirmButton({ + onConfirm, + children, + confirmLabel = 'Confirm?', + className, + confirmClassName, + timeoutMs = 3000, + 'aria-label': ariaLabel, +}: ConfirmButtonProps) { + const [armed, setArmed] = useState(false) + const timerRef = useRef | null>(null) + + const reset = useCallback(() => { + setArmed(false) + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const handleClick = () => { + if (armed) { + reset() + onConfirm() + } else { + setArmed(true) + timerRef.current = setTimeout(reset, timeoutMs) + } + } + + return ( + + ) +} + +export default ConfirmButton diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 3c347df1..10ce7fda 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -5,7 +5,6 @@ import { AlertTriangle, ArrowRight, Building2, - CalendarClock, Check, Clock, Copy, @@ -31,6 +30,7 @@ import { TransferOwnershipModal } from '@/components/account/TransferOwnershipMo import { LeaveAccountModal } from '@/components/account/LeaveAccountModal' import { DeleteAccountModal } from '@/components/account/DeleteAccountModal' import { Button } from '@/components/ui/Button' +import { ConfirmButton } from '@/components/common/ConfirmButton' import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { usePermissions } from '@/hooks/usePermissions' @@ -56,12 +56,12 @@ function SettingsLinkCard({ to, icon, title, description, badge }: SettingsLinkC >
-
{icon}
+
{icon}

{title}

{badge && ( - + {badge} )} @@ -75,37 +75,6 @@ function SettingsLinkCard({ to, icon, title, description, badge }: SettingsLinkC ) } -function OverviewStat({ - icon, - label, - value, - tone = 'default', -}: { - icon: React.ReactNode - label: string - value: string - tone?: 'default' | 'good' | 'warn' -}) { - return ( -
-
- - {icon} - - {label} -
-

{value}

-
- ) -} - function UsageStat({ label, current, @@ -135,7 +104,7 @@ function UsageStat({

{!isUnlimited && ( -
+
invites.filter((invite) => !invite.used_at), [invites]) const ownerMember = useMemo(() => members.find((member) => member.account_role === 'owner') ?? null, [members]) - const currentMembership = useMemo(() => members.find((member) => member.id === user?.id) ?? null, [members, user?.id]) + const handleCopyDisplayCode = async () => { if (!account?.display_code) return @@ -312,72 +281,11 @@ export function AccountSettingsPage() { <>
-
-
-
-
-
- -
-
-

- Account Management -

-

- Manage your account identity, billing, access, and workspace settings from one place. -

-
-
- -
- - {plan.charAt(0).toUpperCase() + plan.slice(1)} plan - - {sub && ( - - {sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')} - - )} - - {isAccountOwner ? 'Owner access' : `Role: ${currentMembership?.account_role ?? user?.account_role ?? 'member'}`} - -
-
- -
- } - label="Members" - value={isAccountOwner ? String(members.length) : String(usage?.user_count ?? 1)} - /> - } - label="Pending invites" - value={isAccountOwner ? String(pendingInvites.length) : 'Owner only'} - tone={pendingInvites.length > 0 ? 'warn' : 'default'} - /> - } - label="Account created" - value={formatShortDate(account?.created_at)} - /> - } - label="Security" - value={plan === 'team' ? 'SSO ready' : 'Password auth'} - tone={plan === 'team' ? 'good' : 'default'} - /> -
-
+
+

Account Management

+

+ Manage your account identity, billing, access, and workspace settings. +

@@ -452,7 +360,7 @@ export function AccountSettingsPage() {

- Useful when admins or teammates need to join this account. + Share this code with teammates so they can join your account during registration.

@@ -610,6 +518,14 @@ export function AccountSettingsPage() {
) : ( -

No pending invites right now.

+

No pending invites. Use the form above to invite teammates by email.

)}
)} @@ -784,7 +702,7 @@ export function AccountSettingsPage() { toast.success('Preference saved') }} className={cn( - 'mt-2 block w-full rounded-xl border border-border bg-card px-3 py-2', + 'mt-2 block w-full rounded-md border border-border bg-card px-3 py-2', 'text-sm text-foreground', 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' )} diff --git a/frontend/src/pages/admin/AccountDetailPage.tsx b/frontend/src/pages/admin/AccountDetailPage.tsx index 3d82cfd6..fef6297e 100644 --- a/frontend/src/pages/admin/AccountDetailPage.tsx +++ b/frontend/src/pages/admin/AccountDetailPage.tsx @@ -19,6 +19,7 @@ import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Modal } from '@/components/common/Modal' import { EmptyState, StatusBadge } from '@/components/admin' +import { ConfirmButton } from '@/components/common/ConfirmButton' import { adminApi } from '@/api/admin' import { toast } from '@/lib/toast' import { cn } from '@/lib/utils' @@ -57,11 +58,11 @@ export function AccountDetailPage() { }) const [inviteLoading, setInviteLoading] = useState(false) - const [planModalOpen, setPlanModalOpen] = useState(false) + const [editingPlan, setEditingPlan] = useState(false) const [selectedPlan, setSelectedPlan] = useState('free') const [planSaving, setPlanSaving] = useState(false) - const [trialModalOpen, setTrialModalOpen] = useState(false) + const [editingTrial, setEditingTrial] = useState(false) const [trialDays, setTrialDays] = useState('14') const [trialSaving, setTrialSaving] = useState(false) @@ -189,7 +190,7 @@ export function AccountDetailPage() { try { await adminApi.updateAccountSubscriptionPlan(account.id, selectedPlan) toast.success(`Plan updated to ${selectedPlan}`) - setPlanModalOpen(false) + setEditingPlan(false) loadAccount() } catch { toast.error('Failed to update plan') @@ -204,7 +205,7 @@ export function AccountDetailPage() { try { await adminApi.extendAccountTrial(account.id, parseInt(trialDays, 10)) toast.success(`Trial updated by ${trialDays} days`) - setTrialModalOpen(false) + setEditingTrial(false) loadAccount() } catch { toast.error('Failed to update trial') @@ -249,7 +250,7 @@ export function AccountDetailPage() {
@@ -257,7 +258,7 @@ export function AccountDetailPage() {

{account.name}

- {account.display_code} + {account.display_code}

Manage account settings, subscription, invites, and users from one place. @@ -367,10 +368,22 @@ export function AccountDetailPage() { - + {member.is_active ? ( + handleToggleActive(member)} + confirmLabel="Confirm deactivate?" + className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-elevated" + confirmClassName="inline-flex items-center rounded-md border border-danger/30 bg-danger-dim px-3 py-1.5 text-sm font-medium text-danger transition-colors" + > + + Deactivate + + ) : ( + + )} @@ -379,8 +392,9 @@ export function AccountDetailPage() {

)) ) : ( -
- No users yet. Create or invite someone into this account. +
+

No users yet.

+

Use Create User or Invite User above to add members.

)}
@@ -414,29 +428,71 @@ export function AccountDetailPage() {
-
- - -
+ {editingPlan ? ( +
+ + + +
+ ) : editingTrial ? ( +
+ setTrialDays(e.target.value)} + className="w-24" + placeholder="Days" + /> + + +
+ ) : ( +
+ + +
+ )}

Pending Invites

- {account.pending_invite_count} + {account.pending_invite_count > 0 && ( + {account.pending_invite_count} pending + )}
{account.invites.length > 0 ? ( @@ -450,8 +506,9 @@ export function AccountDetailPage() {
)) ) : ( -
- No pending invites for this account. +
+

No pending invites.

+

Use Invite User above to send an invitation.

)}
@@ -561,7 +618,7 @@ export function AccountDetailPage() { @@ -569,49 +626,6 @@ export function AccountDetailPage() {
- setPlanModalOpen(false)} - title="Change Account Plan" - size="sm" - footer={( -
- - -
- )} - > - -
- - setTrialModalOpen(false)} - title="Start or Extend Trial" - size="sm" - footer={( -
- - -
- )} - > -
- - setTrialDays(e.target.value)} /> -
-
) } diff --git a/frontend/src/pages/admin/AccountsPage.tsx b/frontend/src/pages/admin/AccountsPage.tsx new file mode 100644 index 00000000..4baf6fbe --- /dev/null +++ b/frontend/src/pages/admin/AccountsPage.tsx @@ -0,0 +1,821 @@ +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([]) + 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([]) + 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 'engineer' | 'viewer', + send_email: true, + }) + const [createLoading, setCreateLoading] = useState(false) + const [tempPassword, setTempPassword] = useState(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' }) + 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, + }) + toast.success('Account created') + setShowCreateAccountModal(false) + setCreateAccountForm({ name: '', plan: 'free' }) + navigate(`/admin/accounts/${created.id}`) + } catch { + toast.error('Failed to create account') + } finally { + setCreateAccountLoading(false) + } + } + + const accountColumns: Column[] = [ + { + key: 'name', + header: 'Account', + render: (account) => ( +
+ +

+ {account.display_code} + {account.owner ? ` · ${account.owner.name}` : ''} +

+
+ ), + }, + { + key: 'plan', + header: 'Plan', + render: (account) => ( + + {account.subscription?.plan ?? 'free'} + + ), + className: 'w-[100px]', + }, + { + key: 'status', + header: 'Status', + render: (account) => { + if (!account.subscription) { + return No subscription + } + return ( + + {account.subscription.status} + + ) + }, + className: 'w-[120px]', + }, + { + key: 'members', + header: 'Members', + render: (account) => ( + + {account.active_member_count} + / {account.member_count} + + ), + className: 'w-[100px]', + }, + { + key: 'usage', + header: 'Usage', + render: (account) => ( + + {account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions + + ), + className: 'w-[160px]', + }, + { + key: 'created', + header: 'Created', + render: (account) => ( + {formatDate(account.created_at)} + ), + className: 'w-[100px]', + }, + { + key: 'actions', + header: '', + render: (account) => ( + , + onClick: () => navigate(`/admin/accounts/${account.id}`), + }, + ...(account.owner ? [{ + label: 'View Owner', + icon: , + onClick: () => navigate(`/admin/users/${account.owner?.id}`), + }] : []), + ]} + /> + ), + className: 'w-[48px]', + }, + ] + + const peopleColumns: Column[] = [ + { + key: 'name', + header: 'Name', + render: (user) => ( +
+ +

{user.email}

+
+ ), + }, + { + key: 'role', + header: 'Role', + render: (user) => ( +
+ {user.is_super_admin && Super Admin} + {user.role} +
+ ), + className: 'w-[140px]', + }, + { + key: 'account', + header: 'Account', + render: (user) => ( + + {user.account_name || 'No account'} + {user.account_display_code && ( + {user.account_display_code} + )} + + ), + }, + { + key: 'status', + header: 'Status', + render: (user) => ( +
+ + {user.is_active ? 'Active' : 'Inactive'} + + {user.deleted_at && Archived} +
+ ), + className: 'w-[140px]', + }, + { + key: 'last_login', + header: 'Last Login', + render: (user) => ( + {formatDate(user.last_login)} + ), + className: 'w-[100px]', + }, + { + key: 'actions', + header: '', + render: (user) => ( + , + 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 ( +
+ + + + +
+ } + /> + + {/* Filters */} +
+ { + setAccountSearch(value) + setPage(1) + }} + placeholder="Search accounts, owners, or codes..." + className="w-full sm:max-w-sm" + /> +
+ + + +
+
+ + {/* Accounts table */} +
+
+

+ {accountsLoading ? 'Loading...' : `${total} accounts`} +

+
+ + a.id} + isLoading={accountsLoading} + skeletonRows={6} + emptyState={ + } + title="No accounts found" + description="Adjust the filters or clear the search." + /> + } + /> + + +
+ + {/* Global people search */} +
+
+
+ +

Global People Search

+
+

+ Find a user across all accounts by name or email. +

+
+ + { + setPeopleSearch(value) + setPeoplePage(1) + }} + placeholder="Search by name, email, or account..." + className="max-w-sm" + /> + + {peopleSearch.trim() ? ( + people.length > 0 ? ( +
+ p.id} + isLoading={peopleLoading} + skeletonRows={4} + emptyState={ + } + title="No matching people" + description="Try another name or email." + /> + } + /> + +
+ ) : !peopleLoading ? ( + } + title="No matching people" + description="Try another name or email." + /> + ) : ( +
+ + Searching... +
+ ) + ) : ( +

Type a name or email to search.

+ )} +
+ + {/* Create Account modal */} + setShowCreateAccountModal(false)} + title="Create Account" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setCreateAccountForm((form) => ({ ...form, name: e.target.value }))} + placeholder="Acme MSP" + /> +
+
+ + +
+
+
+ + {/* Create User modal */} + setShowCreateModal(false)} + title="Create User" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setCreateForm((form) => ({ ...form, name: e.target.value }))} + placeholder="Full name" + /> +
+
+ + setCreateForm((form) => ({ ...form, email: e.target.value }))} + placeholder="user@example.com" + /> +
+
+ + +
+ {createForm.account_mode === 'existing' && ( + <> +
+ + setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))} + placeholder="e.g. ABC12345" + /> +
+
+ + +
+ + )} +
+ setCreateForm((form) => ({ ...form, send_email: e.target.checked }))} + className="rounded border-border bg-card" + /> + +
+
+
+ + {/* Temp password modal */} + setTempPassword(null)} + title="User Created" + size="sm" + footer={( +
+ +
+ )} + > +
+
+ This password will not be shown again. Copy it now. +
+
+ +
+ + {tempPassword} + + +
+
+

+ The user will be required to change this password on first login. +

+
+
+ + {/* Invite User modal */} + setShowInviteModal(false)} + title="Invite User" + size="sm" + footer={( +
+ + +
+ )} + > +
+
+ + setInviteForm((form) => ({ ...form, email: e.target.value }))} + placeholder="user@example.com" + /> +
+
+ + setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))} + placeholder="e.g. ABC12345" + /> +
+
+ + +
+
+
+
+ ) +} + +export default UsersPage diff --git a/frontend/src/pages/admin/UsersPage.tsx b/frontend/src/pages/admin/UsersPage.tsx deleted file mode 100644 index 09cc1bfb..00000000 --- a/frontend/src/pages/admin/UsersPage.tsx +++ /dev/null @@ -1,1108 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { - ArrowRightLeft, - Building2, - CalendarClock, - Check, - Copy, - Crown, - ExternalLink, - Loader2, - Mail, - Plus, - Search, - Shield, - Sparkles, - UserCheck, - UserPlus, - UserX, - Users, -} from 'lucide-react' -import { Button } from '@/components/ui/Button' -import { Input } from '@/components/ui/Input' -import { EmptyState, PageHeader, Pagination, SearchInput, StatusBadge, ActionMenu } 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 { - AccountFeatureOverrideResponse, - AccountOverrideResponse, - AdminAccountListItem, - AdminAccountMember, - AdminUserListItem, -} from '@/types/admin' - -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, - } -} - -function UsageMini({ - label, - current, -}: { - label: string - current: number -}) { - return ( -
-

{label}

-

{current}

-
- ) -} - -export function UsersPage() { - const navigate = useNavigate() - - const [accounts, setAccounts] = useState([]) - const [accountOverrides, setAccountOverrides] = useState([]) - const [featureOverrides, setFeatureOverrides] = useState([]) - 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 = 8 - const [showArchived, setShowArchived] = useState(false) - - const [people, setPeople] = useState([]) - const [peopleLoading, setPeopleLoading] = useState(false) - const [peopleSearch, setPeopleSearch] = useState('') - const [peoplePage, setPeoplePage] = useState(1) - const [peopleTotal, setPeopleTotal] = useState(0) - const peoplePageSize = 12 - - const [roleModalUser, setRoleModalUser] = useState(null) - const [newRole, setNewRole] = useState('') - const [moveModalUser, setMoveModalUser] = useState(null) - const [displayCode, setDisplayCode] = useState('') - const [showCreateModal, setShowCreateModal] = useState(false) - const [createForm, setCreateForm] = useState({ - email: '', - name: '', - account_mode: 'personal' as 'existing' | 'personal', - account_display_code: '', - account_role: 'engineer' as 'engineer' | 'viewer', - send_email: true, - }) - const [createLoading, setCreateLoading] = useState(false) - const [tempPassword, setTempPassword] = useState(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 [planModalAccount, setPlanModalAccount] = useState(null) - const [selectedPlan, setSelectedPlan] = useState('free') - const [planSaving, setPlanSaving] = useState(false) - const [trialModalAccount, setTrialModalAccount] = useState(null) - const [trialDays, setTrialDays] = useState('14') - const [trialSaving, setTrialSaving] = useState(false) - const [showCreateAccountModal, setShowCreateAccountModal] = useState(false) - const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team' }) - const [createAccountLoading, setCreateAccountLoading] = useState(false) - - const fetchAccounts = useCallback(async () => { - setAccountsLoading(true) - try { - const [accountsData, overridesData, featureOverrideData] = await Promise.all([ - adminApi.listAccounts({ - page, - size: accountPageSize, - search: accountSearch || undefined, - plan: planFilter !== 'all' ? planFilter : undefined, - status: statusFilter !== 'all' ? statusFilter : undefined, - include_archived: showArchived || undefined, - }), - adminApi.listAccountOverrides(), - adminApi.listFeatureFlagOverrides(), - ]) - setAccounts(accountsData.items) - setTotal(accountsData.total) - setAccountOverrides(overridesData) - setFeatureOverrides(featureOverrideData) - } 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 limitOverrideByAccount = useMemo( - () => new Map(accountOverrides.map((override) => [override.account_id, override])), - [accountOverrides] - ) - - const featureOverrideCounts = useMemo(() => { - const counts = new Map() - for (const override of featureOverrides) { - counts.set(override.account_id, (counts.get(override.account_id) ?? 0) + 1) - } - return counts - }, [featureOverrides]) - - const handleRoleChange = async () => { - if (!roleModalUser || !newRole) return - try { - await adminApi.updateUserRole(roleModalUser.id, newRole) - toast.success('Role updated') - setRoleModalUser(null) - if (peopleSearch.trim()) { - fetchPeople() - } - fetchAccounts() - } catch { - toast.error('Failed to update role') - } - } - - const handleToggleActive = async (user: UserRecord) => { - try { - if (user.is_active) { - await adminApi.deactivateUser(user.id) - toast.success('User deactivated') - } else { - await adminApi.activateUser(user.id) - toast.success('User activated') - } - if (peopleSearch.trim()) { - fetchPeople() - } - fetchAccounts() - } 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('') - if (peopleSearch.trim()) { - fetchPeople() - } - fetchAccounts() - } catch { - toast.error('Failed to move user') - } - } - - 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 handleUpdateAccountPlan = async () => { - if (!planModalAccount) return - setPlanSaving(true) - try { - await adminApi.updateAccountSubscriptionPlan(planModalAccount.id, selectedPlan) - toast.success(`Plan updated to ${selectedPlan}`) - setPlanModalAccount(null) - fetchAccounts() - } catch { - toast.error('Failed to update account plan') - } finally { - setPlanSaving(false) - } - } - - const handleExtendTrial = async () => { - if (!trialModalAccount || !trialDays) return - setTrialSaving(true) - try { - await adminApi.extendAccountTrial(trialModalAccount.id, parseInt(trialDays, 10)) - toast.success(`Trial extended by ${trialDays} days`) - setTrialModalAccount(null) - fetchAccounts() - } catch { - toast.error('Failed to update trial') - } finally { - setTrialSaving(false) - } - } - - const handleCreateAccount = async () => { - if (!createAccountForm.name.trim()) return - setCreateAccountLoading(true) - try { - const created = await adminApi.createAccount({ - name: createAccountForm.name.trim(), - plan: createAccountForm.plan, - }) - toast.success('Account created') - setShowCreateAccountModal(false) - setCreateAccountForm({ name: '', plan: 'free' }) - navigate(`/admin/accounts/${created.id}`) - } catch { - toast.error('Failed to create account') - } finally { - setCreateAccountLoading(false) - } - } - - const userActions = (user: UserRecord) => ([ - { - label: 'View Detail', - icon: , - onClick: () => navigate(`/admin/users/${user.id}`), - }, - { - label: 'Change Role', - icon: , - onClick: () => { - setRoleModalUser(user) - setNewRole(user.role) - }, - }, - { - label: user.is_active ? 'Deactivate' : 'Activate', - icon: user.is_active ? : , - onClick: () => handleToggleActive(user), - destructive: user.is_active, - }, - { - label: 'Move Account', - icon: , - onClick: () => { - setMoveModalUser(user) - setDisplayCode('') - }, - }, - ]) - - const renderUserRow = (user: UserRecord) => ( -
-
-
-

{user.name}

- {user.is_super_admin && Super Admin} - {user.account_role && {user.account_role}} - - {user.is_active ? 'Active' : 'Inactive'} - - {user.deleted_at && Archived} -
-

{user.email}

-
- Platform role: {user.role} - Account: {user.account_name || 'No account'} - {user.account_display_code && Code: {user.account_display_code}} - Last login: {formatDate(user.last_login)} -
-
-
- -
-
- ) - - const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize)) - const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize)) - - return ( -
-
- -
- - - -
-
- -
-
-
-

- Customer Account Management -

-

- Search customers, inspect subscription health, and manage account-level SaaS controls from here. -

-
-
- { - setAccountSearch(value) - setPage(1) - }} - placeholder="Search accounts, owners, or display codes..." - className="w-full xl:min-w-[320px]" - /> - - - -
-
-
- -
-
-
- -
-
-

Customer Accounts

-

- {accountsLoading ? 'Loading account records...' : `${total} customer accounts match the current filters.`} -

-
-
- - {accounts.length > 0 ? ( -
- {accounts.map((account) => { - const limitOverride = limitOverrideByAccount.get(account.id) - const featureOverrideCount = featureOverrideCounts.get(account.id) ?? 0 - - return ( -
-
-
- -
- {account.subscription ? ( - <> - - {account.subscription.plan} - - - {account.subscription.status} - - - ) : ( - No subscription - )} - {account.sso_enabled && SSO} -
-
- -
- - - {account.owner && ( - - )} - - -
-
- -
- - - - -
- -
- {account.pending_invite_count} pending invites - - {limitOverride ? 'Custom limits' : 'Default limits'} - - 0 ? 'warning' : 'default'}> - {featureOverrideCount} feature overrides - - {account.subscription?.cancel_at_period_end && ( - Cancels at period end - )} -
- -
-
-
Renewal
-
- {formatDate(account.subscription?.current_period_end ?? null)} -
-
-
-
Created
-
{formatDate(account.created_at)}
-
-
- -
-
- -

Members

-
-
- {account.members.length > 0 ? ( - account.members.slice(0, 3).map((member) => renderUserRow(buildMemberRecord(account, member))) - ) : ( -
- No members found in this account. -
- )} - {account.members.length > 3 && ( -
- +{account.members.length - 3} more members in this account -
- )} -
-
-
- ) - })} -
- ) : !accountsLoading ? ( - } - title="No customer accounts found" - description="Adjust the plan or status filters, or clear the account search." - /> - ) : null} - - -
- -
-
-
- -
-
-

Global People Search

-

- Find a user anywhere across customer accounts without leaving the account management view. -

-
-
- - { - setPeopleSearch(value) - setPeoplePage(1) - }} - placeholder="Search people by name, email, or account..." - className="max-w-lg" - /> - - {peopleSearch.trim() ? ( - people.length > 0 ? ( -
- {people.map((person) => renderUserRow(person))} - -
- ) : !peopleLoading ? ( - } - title="No matching people" - description="Try another name or email to find the person you need." - /> - ) : ( -
- - Searching people... -
- ) - ) : ( -

Type a name or email to search individual users globally.

- )} -
- - setShowCreateAccountModal(false)} - title="Create Account" - size="sm" - footer={( -
- - -
- )} - > -
-
- - setCreateAccountForm((form) => ({ ...form, name: e.target.value }))} - placeholder="Acme MSP" - /> -
-
- - -
-
-
- - setRoleModalUser(null)} - title="Change Role" - size="sm" - footer={( -
- - -
- )} - > -
-

- Changing role for {roleModalUser?.name} -

- -
-
- - setMoveModalUser(null)} - title="Move User to Account" - size="sm" - footer={( -
- - -
- )} - > -
-

- Moving {moveModalUser?.name} to a new account. -

-
- - setDisplayCode(e.target.value)} - placeholder="e.g. ABC12345" - /> -
-
-
- - setShowCreateModal(false)} - title="Create User" - size="sm" - footer={( -
- - -
- )} - > -
-
- - setCreateForm((form) => ({ ...form, name: e.target.value }))} - placeholder="Full name" - /> -
-
- - setCreateForm((form) => ({ ...form, email: e.target.value }))} - placeholder="user@example.com" - /> -
-
- - -
- {createForm.account_mode === 'existing' && ( - <> -
- - setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))} - placeholder="e.g. ABC12345" - /> -
-
- - -
- - )} -
- setCreateForm((form) => ({ ...form, send_email: e.target.checked }))} - className="rounded border-border bg-card" - /> - -
-
-
- - setTempPassword(null)} - title="User Created" - size="sm" - footer={( -
- -
- )} - > -
-
- This password will not be shown again. Copy it now. -
-
- -
- - {tempPassword} - - -
-
-

- The user will be required to change this password on first login. -

-
-
- - setShowInviteModal(false)} - title="Invite User" - size="sm" - footer={( -
- - -
- )} - > -
-
- - setInviteForm((form) => ({ ...form, email: e.target.value }))} - placeholder="user@example.com" - /> -
-
- - setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))} - placeholder="e.g. ABC12345" - /> -
-
- - -
-
-
- - setPlanModalAccount(null)} - title="Change Account Plan" - size="sm" - footer={( -
- - -
- )} - > -
-

- Updating plan for {planModalAccount?.name}. -

- -
-
- - setTrialModalAccount(null)} - title="Start or Extend Trial" - size="sm" - footer={( -
- - -
- )} - > -
-

- Starting or extending trial for {trialModalAccount?.name}. -

-
- - setTrialDays(e.target.value)} - /> -
-
-
-
- ) -} - -export default UsersPage diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index fec74d59..22333b03 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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 AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/UsersPage')) +const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/AccountsPage')) const AdminAccountDetailPage = lazyWithRetry(() => import('@/pages/admin/AccountDetailPage')) const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage')) const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))