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 3c47835d..10ce7fda 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -1,13 +1,36 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' -import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug, Palette, ShieldCheck } from 'lucide-react' +import { + AlertCircle, + AlertTriangle, + ArrowRight, + Building2, + Check, + Clock, + Copy, + Crown, + FolderTree, + Loader2, + Mail, + MessageSquareText, + Palette, + Plug, + RefreshCw, + Server, + Settings, + ShieldCheck, + UserCog, + Users, + X, +} from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' import { accountsApi } from '@/api/accounts' -import type { Account, AccountMember, AccountInvite } from '@/types' +import type { Account, AccountInvite, AccountMember } from '@/types' import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal' 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' @@ -17,32 +40,116 @@ import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { CheckoutButton } from '@/components/subscription/CheckoutButton' import { toast } from '@/lib/toast' +interface SettingsLinkCardProps { + to: string + icon: React.ReactNode + title: string + description: string + badge?: string +} + +function SettingsLinkCard({ to, icon, title, description, badge }: SettingsLinkCardProps) { + return ( + +
+
+
{icon}
+
+
+

{title}

+ {badge && ( + + {badge} + + )} +
+

{description}

+
+
+ +
+ + ) +} + +function UsageStat({ + label, + current, + max, +}: { + label: string + current: number + max: number | null +}) { + const isUnlimited = max === null + const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100) + const isNearLimit = !isUnlimited && percentage >= 80 + const isAtLimit = !isUnlimited && current >= max + + return ( +
+

{label}

+

+ {current} + + / {isUnlimited ? 'Unlimited' : max} + +

+ {!isUnlimited && ( +
+
+
+ )} +
+ ) +} + +function formatShortDate(value: string | null | undefined) { + if (!value) return 'Never' + return new Date(value).toLocaleDateString() +} + export function AccountSettingsPage() { const { isAccountOwner } = usePermissions() const { plan, limits, usage } = useSubscription() const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore() const subscription = useAuthStore((s) => s.subscription) + const user = useAuthStore((s) => s.user) + const refreshUser = useAuthStore((s) => s.fetchUser) const [account, setAccount] = useState(null) const [members, setMembers] = useState([]) const [invites, setInvites] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) + const [copiedCode, setCopiedCode] = useState(false) - // Account name editing const [isEditingName, setIsEditingName] = useState(false) const [editedName, setEditedName] = useState('') const [isSavingName, setIsSavingName] = useState(false) - // Modals const [showTransferModal, setShowTransferModal] = useState(false) const [showLeaveModal, setShowLeaveModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) - // Invite form const [inviteEmail, setInviteEmail] = useState('') const [inviteRole, setInviteRole] = useState('engineer') const [isInviting, setIsInviting] = useState(false) + const [resendingId, setResendingId] = useState(null) useEffect(() => { loadData() @@ -72,6 +179,18 @@ export function AccountSettingsPage() { } } + const pendingInvites = useMemo(() => invites.filter((invite) => !invite.used_at), [invites]) + const ownerMember = useMemo(() => members.find((member) => member.account_role === 'owner') ?? null, [members]) + + + const handleCopyDisplayCode = async () => { + if (!account?.display_code) return + await navigator.clipboard.writeText(account.display_code) + setCopiedCode(true) + toast.success('Display code copied') + setTimeout(() => setCopiedCode(false), 2000) + } + const handleSaveName = async () => { if (!editedName.trim() || editedName === account?.name) { setIsEditingName(false) @@ -83,6 +202,7 @@ export function AccountSettingsPage() { setAccount(updated) setIsEditingName(false) toast.success('Account name updated') + await refreshUser() } catch (err) { toast.error('Failed to update account name') console.error('Failed to update account name:', err) @@ -110,8 +230,6 @@ export function AccountSettingsPage() { } } - const [resendingId, setResendingId] = useState(null) - const handleResendInvite = async (inviteId: string) => { setResendingId(inviteId) try { @@ -129,8 +247,9 @@ export function AccountSettingsPage() { const handleRemoveMember = async (userId: string) => { try { await accountsApi.removeMember(userId) - setMembers(members.filter((m) => m.id !== userId)) + setMembers((current) => current.filter((member) => member.id !== userId)) toast.success('Member removed') + await refreshUser() } catch (err) { toast.error('Failed to remove member') console.error('Failed to remove member:', err) @@ -160,622 +279,578 @@ export function AccountSettingsPage() { return ( <> - -
-
-
- -

Account Settings

-
-

- Manage your account, subscription, and team -

-
- -
- {/* Account Info Section */} -
-

Account Information

- -
- {/* Account Name */} -
- - {isEditingName ? ( -
- setEditedName(e.target.value)} - className={cn( - 'flex-1 rounded-md border border-border bg-card px-3 py-2', - 'text-foreground placeholder:text-muted-foreground', - 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' - )} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveName() - if (e.key === 'Escape') { - setEditedName(account?.name ?? '') - setIsEditingName(false) - } - }} - /> - - -
- ) : ( -
- {account?.name} - {isAccountOwner && ( - - )} -
- )} -
- - {/* Display Code */} -
- -

- {account?.display_code} -

-
-
+ +
+
+

Account Management

+

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

- {/* Subscription Section */} -
-

Subscription

- -
- {/* Plan & Status */} -
- - - {plan.charAt(0).toUpperCase() + plan.slice(1)} Plan - - {sub && ( - - {sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')} - - )} -
- - {sub?.current_period_end && ( -

- Current period ends: {new Date(sub.current_period_end).toLocaleDateString()} -

- )} - - {/* Usage Stats */} - {limits && usage && ( -
- - - +
+
+
+
+ +

Account Identity

- )} - {/* Upgrade buttons */} - {plan === 'free' && ( -
- - -
- )} - {plan === 'pro' && ( -
- -
- )} -
-
- - {/* Team Members Section (owners only) */} - {isAccountOwner && ( -
-
- -

Team Members

-
- - {members.length === 0 ? ( -

No team members yet.

- ) : ( -
- {members.map((member) => ( -
-
-

{member.name}

-

{member.email}

+
+
+ + {isEditingName ? ( +
+ setEditedName(e.target.value)} + className={cn( + 'flex-1 rounded-md border border-border bg-card px-3 py-2', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' + )} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveName() + if (e.key === 'Escape') { + setEditedName(account?.name ?? '') + setIsEditingName(false) + } + }} + /> + +
-
- {member.account_role === 'owner' ? ( - - owner - - ) : ( - - )} - {!member.is_active && ( - - Inactive - - )} - {member.account_role !== 'owner' && ( + ) : ( +
+ {account?.name} + {isAccountOwner && ( )}
-
- ))} -
- )} -
- )} + )} +
- {/* Invite Member Section (owners only) */} - {isAccountOwner && ( -
-
- -

Invite Member

+
+
+ +
+ + {account?.display_code} + + +
+

+ Share this code with teammates so they can join your account during registration. +

+
+ +
+ +
+ {ownerMember ? ownerMember.name : user?.account_role === 'owner' ? user.email : 'Owner unavailable'} +
+

+ {ownerMember?.email ?? 'The owner manages billing, branding, integrations, and membership changes.'} +

+
+
+ +
+
+ +

{formatShortDate(account?.created_at)}

+
+
+ +

{formatShortDate(account?.updated_at)}

+
+
+
-
-
- setInviteEmail(e.target.value)} - required - className={cn( - 'flex-1 rounded-md border border-border bg-card px-3 py-2', - 'text-foreground placeholder:text-muted-foreground', - 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' - )} - /> - - +
+
+
+
+ +

Billing & Usage

+
+

+ Monitor plan status, renewal timing, and current account limits. +

+
- +
+ + + {plan.charAt(0).toUpperCase() + plan.slice(1)} Plan + + {sub && ( + + {sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')} + + )} +
- {/* Pending Invites */} - {invites.length > 0 && ( -
-

Pending Invites

-
- {invites - .filter((inv) => !inv.used_at) - .map((invite) => ( -
-
-

{invite.email}

-

- {invite.expires_at - ? `Expires ${new Date(invite.expires_at).toLocaleDateString()}` - : 'No expiration'} -

-
-
- - {invite.role} - -
+ +
+
+ +

Access & Security

+
+ +
+
+

Authentication

+

+ {plan === 'team' ? 'Password auth available, SSO can be enabled.' : 'Password-based authentication is active.'} +

+

+ Use profile settings to update your personal details and sign-in information. +

+
+ +
+

Single Sign-On

+

+ {plan === 'team' ? 'Enterprise-ready setup available.' : 'Available on higher-tier account setups.'} +

+

+ Contact support to configure SAML or OIDC for your organization. +

+
+
+ + {isAccountOwner && ( +
+
+
+

Need enterprise security controls?

+

+ We can help enable SSO and align account security for larger teams. +

+
+ + Contact Us + +
+
+ )} +
+ + +
)} -
- )} - {/* Profile Settings Link */} - -
- -
-

Profile Settings

-

Update your name, email, and personal details

-
-
- - - - {/* Team Settings section (owners only) */} - {isAccountOwner && ( - <> -

- Team Settings -

- - -
- -
-

Team Categories

-

Manage flow categories for your team

+ {isAccountOwner && ( +
+
+ +

Invites

-
- - - -
- -
-

Target Lists

-

Saved server and device lists for your team

-
-
- - - - -
- -
-

Chat Retention

-

Configure AI assistant conversation retention policies

-
-
- - - - -
- -
-

Integrations

-

Connect your PSA to sync session documentation to tickets

-
-
- - - - -
- -
-

Branding

-

Customize logo, accent color, and company name

-
-
- - - - )} - - {/* Feedback Link (all users) */} - -
- -
-

Send Feedback

-

Report bugs, request features, or share your thoughts

-
-
- - - - {/* Preferences Section */} -
-
- -

Preferences

-
- -
- -

- This format will be pre-selected when exporting sessions -

- -
-
- {/* SSO Section */} - {isAccountOwner && ( -
-
- -

Single Sign-On (SSO)

- - Enterprise - -
-

- SAML and OIDC single sign-on is available for enterprise plans. Contact us to enable SSO for - your organization. -

- - Contact Us - -
- )} - - {/* Danger Zone */} -
-
- -

Danger Zone

-
- -
- {isAccountOwner ? ( - <> -
-
-

Transfer Ownership

-

Make another member the account owner

+
+ setInviteEmail(e.target.value)} + required + className={cn( + 'w-full rounded-md border border-border bg-card px-3 py-2', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' + )} + /> +
+ +
- -
-
-
-

Delete Account

-

Permanently delete your account and all data

+ + + {pendingInvites.length > 0 ? ( +
+ {pendingInvites.map((invite) => ( +
+
+
+

{invite.email}

+

+ {invite.expires_at + ? `Expires ${new Date(invite.expires_at).toLocaleDateString()}` + : 'No expiration'} +

+
+
+ + {invite.role} + + +
+
+
+ ))}
- -
- - ) : ( -
-
-

Leave Account

-

Leave this account and create a personal one

-
-
+ )} + +
+
+ +

Preferences

+
+ +
+ +

+ This format will be pre-selected when exporting sessions. +

+
- )} -
-
-
+
- {/* Modals */} - {showTransferModal && ( - setShowTransferModal(false)} - onTransferred={() => { setShowTransferModal(false); loadData() }} - /> - )} - {showLeaveModal && account && ( - setShowLeaveModal(false)} - /> - )} - {showDeleteModal && ( - setShowDeleteModal(false)} /> - )} -
+
+
+ +

Danger Zone

+
+ +
+ {isAccountOwner ? ( + <> +
+
+

Transfer Ownership

+

Make another member the account owner.

+
+ +
+
+
+

Delete Account

+

Permanently delete your account and all data.

+
+ +
+ + ) : ( +
+
+

Leave Account

+

Leave this account and create a personal one.

+
+ +
+ )} +
+
+ +
+ +
+
+

Settings Areas

+

+ Common account management actions, organized the way most SaaS teams expect to find them. +

+
+ +
+ } + title="Profile Settings" + description="Update your name, email, and personal details." + /> + + {isAccountOwner && ( + } + title="Branding" + description="Customize logo, accent color, and company name." + badge={limits?.custom_branding ? 'Included' : 'Plan gated'} + /> + )} + + {isAccountOwner && ( + } + title="Integrations" + description="Connect PSA and other external systems for your team." + /> + )} + + {isAccountOwner && ( + } + title="Chat Retention" + description="Control conversation retention and assistant data lifecycle." + /> + )} + + {isAccountOwner && ( + } + title="Team Categories" + description="Manage shared flow categories for your workspace." + /> + )} + + {isAccountOwner && ( + } + title="Target Lists" + description="Maintain saved server and device lists for the team." + /> + )} + + } + title="Support & Feedback" + description="Report bugs, request features, or share product feedback." + /> +
+
+ + {showTransferModal && ( + setShowTransferModal(false)} + onTransferred={() => { + setShowTransferModal(false) + loadData() + }} + /> + )} + {showLeaveModal && account && ( + setShowLeaveModal(false)} /> + )} + {showDeleteModal && setShowDeleteModal(false)} />} +
) } -/** Small helper component for usage stat display */ -function UsageStat({ - label, - current, - max, -}: { - label: string - current: number - max: number | null -}) { - const isUnlimited = max === null - const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100) - const isNearLimit = !isUnlimited && percentage >= 80 - const isAtLimit = !isUnlimited && current >= max - - return ( -
-

{label}

-

- {current} - - {' '}/ {isUnlimited ? 'Unlimited' : max} - -

- {!isUnlimited && ( -
-
-
- )} -
- ) -} - export default AccountSettingsPage 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'))