diff --git a/sim/app/admin/page.tsx b/sim/app/admin/page.tsx index ec2c099cb61..97f3d7de276 100644 --- a/sim/app/admin/page.tsx +++ b/sim/app/admin/page.tsx @@ -3,8 +3,25 @@ import PasswordAuth from './password-auth' export default function AdminPage() { return ( -
-

Admin Page

+
+
+

Admin Dashboard

+

+ Manage Sim Studio platform settings and users. +

+
+ +
+ +

Waitlist Management

+

+ Review and manage users on the waitlist +

+
+
) diff --git a/sim/app/admin/waitlist/components/batch-actions/batch-actions.tsx b/sim/app/admin/waitlist/components/batch-actions/batch-actions.tsx new file mode 100644 index 00000000000..4457009134f --- /dev/null +++ b/sim/app/admin/waitlist/components/batch-actions/batch-actions.tsx @@ -0,0 +1,70 @@ +import { Button } from '@/components/ui/button' +import { CheckSquareIcon, SquareIcon, UserCheckIcon, XIcon } from 'lucide-react' + +interface BatchActionsProps { + hasSelectedEmails: boolean + selectedCount: number + loading: boolean + onToggleSelectAll: () => void + onClearSelections: () => void + onBatchApprove: () => void + entriesExist: boolean + someSelected: boolean +} + +export function BatchActions({ + hasSelectedEmails, + selectedCount, + loading, + onToggleSelectAll, + onClearSelections, + onBatchApprove, + entriesExist, + someSelected, +}: BatchActionsProps) { + if (!entriesExist) return null; + + return ( +
+ + + {hasSelectedEmails && ( + <> + + + + + )} +
+ ) +} \ No newline at end of file diff --git a/sim/app/admin/waitlist/components/batch-results-modal/batch-results-modal.tsx b/sim/app/admin/waitlist/components/batch-results-modal/batch-results-modal.tsx new file mode 100644 index 00000000000..7d3aad84ac6 --- /dev/null +++ b/sim/app/admin/waitlist/components/batch-results-modal/batch-results-modal.tsx @@ -0,0 +1,79 @@ +import { CheckIcon, XIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +type BatchResult = { + email: string + success: boolean + message: string +} + +interface BatchResultsModalProps { + open: boolean + onOpenChange: (open: boolean) => void + results: Array | null + onClose: () => void +} + +export function BatchResultsModal({ + open, + onOpenChange, + results, + onClose, +}: BatchResultsModalProps) { + return ( + + + + Batch Approval Results + + Results of the batch approval operation. + + +
+ {results && results.length > 0 ? ( +
+
+ Total: {results.length} + + Success: {results.filter(r => r.success).length} / + Failed: {results.filter(r => !r.success).length} + +
+ {results.map((result, idx) => ( +
+
+ {result.success ? : } + {result.email} +
+
{result.message}
+
+ ))} +
+ ) : ( +
No results to display
+ )} +
+ + + +
+
+ ) +} \ No newline at end of file diff --git a/sim/app/admin/waitlist/components/filter-bar/components/filter-button.tsx b/sim/app/admin/waitlist/components/filter-bar/components/filter-button.tsx new file mode 100644 index 00000000000..601a9aad479 --- /dev/null +++ b/sim/app/admin/waitlist/components/filter-bar/components/filter-button.tsx @@ -0,0 +1,24 @@ +import { Button } from '@/components/ui/button' +import { ReactNode } from 'react' + +interface FilterButtonProps { + active: boolean + onClick: () => void + icon: ReactNode + label: string + className?: string +} + +export function FilterButton({ active, onClick, icon, label, className }: FilterButtonProps) { + return ( + + ) +} \ No newline at end of file diff --git a/sim/app/admin/waitlist/components/filter-bar/filter-bar.tsx b/sim/app/admin/waitlist/components/filter-bar/filter-bar.tsx new file mode 100644 index 00000000000..70a16b843f5 --- /dev/null +++ b/sim/app/admin/waitlist/components/filter-bar/filter-bar.tsx @@ -0,0 +1,74 @@ +import { + UserIcon, + UserCheckIcon, + UserXIcon, + CheckIcon +} from 'lucide-react' +import { FilterButton } from './components/filter-button' + +interface FilterBarProps { + currentStatus: string + onStatusChange: (status: string) => void +} + +export function FilterBar({ currentStatus, onStatusChange }: FilterBarProps) { + return ( +
+ onStatusChange('all')} + icon={} + label="All" + className={ + currentStatus === 'all' + ? 'bg-blue-100 text-blue-900 hover:bg-blue-200 hover:text-blue-900' + : '' + } + /> + onStatusChange('pending')} + icon={} + label="Pending" + className={ + currentStatus === 'pending' + ? 'bg-amber-100 text-amber-900 hover:bg-amber-200 hover:text-amber-900' + : '' + } + /> + onStatusChange('approved')} + icon={} + label="Approved" + className={ + currentStatus === 'approved' + ? 'bg-green-100 text-green-900 hover:bg-green-200 hover:text-green-900' + : '' + } + /> + onStatusChange('rejected')} + icon={} + label="Rejected" + className={ + currentStatus === 'rejected' + ? 'bg-red-100 text-red-900 hover:bg-red-200 hover:text-red-900' + : '' + } + /> + onStatusChange('signed_up')} + icon={} + label="Signed Up" + className={ + currentStatus === 'signed_up' + ? 'bg-purple-100 text-purple-900 hover:bg-purple-200 hover:text-purple-900' + : '' + } + /> +
+ ) +} \ No newline at end of file diff --git a/sim/app/admin/waitlist/components/pagination/pagination.tsx b/sim/app/admin/waitlist/components/pagination/pagination.tsx new file mode 100644 index 00000000000..9307251e9d5 --- /dev/null +++ b/sim/app/admin/waitlist/components/pagination/pagination.tsx @@ -0,0 +1,87 @@ +import { Button } from '@/components/ui/button' +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, +} from 'lucide-react' + +interface PaginationProps { + page: number + totalItems: number + itemsPerPage: number + loading: boolean + onFirstPage: () => void + onPrevPage: () => void + onNextPage: () => void + onLastPage: () => void +} + +export function Pagination({ + page, + totalItems, + itemsPerPage, + loading, + onFirstPage, + onPrevPage, + onNextPage, + onLastPage, +}: PaginationProps) { + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)) + + return ( +
+
+ + +
+ + + Page {page} of {totalPages} +  •  + {totalItems} total entries + + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/sim/app/admin/waitlist/components/search-bar/search-bar.tsx b/sim/app/admin/waitlist/components/search-bar/search-bar.tsx new file mode 100644 index 00000000000..772b2dbde3d --- /dev/null +++ b/sim/app/admin/waitlist/components/search-bar/search-bar.tsx @@ -0,0 +1,49 @@ +import { useRef, useState } from 'react' +import { Input } from '@/components/ui/input' +import { SearchIcon } from 'lucide-react' + +interface SearchBarProps { + initialValue: string + onSearch: (value: string) => void + disabled?: boolean + placeholder?: string +} + +export function SearchBar({ + initialValue = '', + onSearch, + disabled = false, + placeholder = 'Search by email...' +}: SearchBarProps) { + const [searchInputValue, setSearchInputValue] = useState(initialValue) + const searchTimeoutRef = useRef(null) + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value + setSearchInputValue(value) + + // Clear any existing timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + + // Set a new timeout for debounce + searchTimeoutRef.current = setTimeout(() => { + onSearch(value) + }, 500) // 500ms debounce + } + + return ( +
+ + +
+ ) +} \ No newline at end of file diff --git a/sim/app/admin/waitlist/components/waitlist-alert/waitlist-alert.tsx b/sim/app/admin/waitlist/components/waitlist-alert/waitlist-alert.tsx new file mode 100644 index 00000000000..84f2a186d58 --- /dev/null +++ b/sim/app/admin/waitlist/components/waitlist-alert/waitlist-alert.tsx @@ -0,0 +1,59 @@ +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { AlertCircleIcon } from 'lucide-react' + +type AlertType = 'error' | 'email-error' | 'rate-limit' | null + +interface WaitlistAlertProps { + type: AlertType + message: string + onDismiss: () => void + onRefresh?: () => void +} + +export function WaitlistAlert({ type, message, onDismiss, onRefresh }: WaitlistAlertProps) { + if (!type) return null + + return ( + + + + {type === 'email-error' + ? 'Email Delivery Failed' + : type === 'rate-limit' + ? 'Rate Limit Exceeded' + : 'Error'} + + + {message} +
+ {onRefresh && ( + + )} + +
+
+
+ ) +} \ No newline at end of file diff --git a/sim/app/admin/waitlist/components/waitlist-table/waitlist-table.tsx b/sim/app/admin/waitlist/components/waitlist-table/waitlist-table.tsx new file mode 100644 index 00000000000..2819fd96ac8 --- /dev/null +++ b/sim/app/admin/waitlist/components/waitlist-table/waitlist-table.tsx @@ -0,0 +1,227 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + CheckIcon, + InfoIcon, + MailIcon, + RotateCcwIcon, + UserCheckIcon, + UserXIcon, + XIcon, + CheckSquareIcon, + SquareIcon, +} from 'lucide-react' + +interface WaitlistEntry { + id: string + email: string + status: string + createdAt: Date +} + +interface WaitlistTableProps { + entries: WaitlistEntry[] + status: string + actionLoading: string | null + selectedEmails: Record + onToggleSelection: (email: string) => void + onApprove: (email: string, id: string) => void + onReject: (email: string, id: string) => void + onResendApproval: (email: string, id: string) => void + formatDate: (date: Date) => string + getDetailedTimeTooltip: (date: Date) => string +} + +export function WaitlistTable({ + entries, + status, + actionLoading, + selectedEmails, + onToggleSelection, + onApprove, + onReject, + onResendApproval, + formatDate, + getDetailedTimeTooltip, +}: WaitlistTableProps) { + return ( +
+ + + + {/* Add selection checkbox column */} + {status !== 'approved' && ( + Select + )} + Email + Status + Date Added + Actions + + + + {entries.map((entry) => ( + + {/* Add selection checkbox */} + {status !== 'approved' && ( + + + + )} + + {entry.email} + + {/* Status badge */} + + {entry.status === 'pending' && } + {entry.status === 'approved' && } + {entry.status === 'rejected' && } + {entry.status === 'signed_up' && } + {entry.status.charAt(0).toUpperCase() + entry.status.slice(1).replace('_', ' ')} + + + + + + + {formatDate(entry.createdAt)} + + {getDetailedTimeTooltip(entry.createdAt)} + + + + +
+ {entry.status !== 'approved' && ( + + + + + + Approve user and send access email + + + )} + + {entry.status === 'approved' && ( + + + + + + + Resend approval email with sign-up link + + + + )} + + {entry.status !== 'rejected' && entry.status !== 'approved' && ( + + + + + + Reject user + + + )} + + + + + + + Email user in Gmail + + +
+
+
+ ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/sim/app/admin/waitlist/page.tsx b/sim/app/admin/waitlist/page.tsx index f61f4c5ac6a..76b2685c758 100644 --- a/sim/app/admin/waitlist/page.tsx +++ b/sim/app/admin/waitlist/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next' import PasswordAuth from '../password-auth' -import { WaitlistTable } from './waitlist-table' +import { WaitlistTable } from './waitlist' export const metadata: Metadata = { title: 'Waitlist Management | Sim Studio', @@ -10,15 +10,15 @@ export const metadata: Metadata = { export default function WaitlistPage() { return ( -
-
-

Waitlist Management

-

+

+
+

Waitlist Management

+

Review and manage users who have signed up for the waitlist.

-
+
diff --git a/sim/app/admin/waitlist/waitlist-table.tsx b/sim/app/admin/waitlist/waitlist-table.tsx deleted file mode 100644 index f417be10d8d..00000000000 --- a/sim/app/admin/waitlist/waitlist-table.tsx +++ /dev/null @@ -1,812 +0,0 @@ -'use client' - -import { useCallback, useEffect, useRef, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { - AlertCircleIcon, - CheckIcon, - ChevronLeftIcon, - ChevronRightIcon, - ChevronsLeftIcon, - ChevronsRightIcon, - InfoIcon, - MailIcon, - RotateCcwIcon, - SearchIcon, - UserCheckIcon, - UserIcon, - UserXIcon, - XIcon, -} from 'lucide-react' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Skeleton } from '@/components/ui/skeleton' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { Logger } from '@/lib/logs/console-logger' -import { useWaitlistStore } from './stores/store' - -const logger = new Logger('WaitlistTable') - -interface FilterButtonProps { - active: boolean - onClick: () => void - icon: React.ReactNode - label: string - className?: string -} - -// Alert types for more specific error display -type AlertType = 'error' | 'email-error' | 'rate-limit' | null - -// Filter button component -const FilterButton = ({ active, onClick, icon, label, className }: FilterButtonProps) => ( - -) - -export function WaitlistTable() { - const router = useRouter() - const searchParams = useSearchParams() - - // Get all values from the store - const { - entries, - filteredEntries, - status, - searchTerm, - page, - totalEntries, - loading, - error, - actionLoading, - setStatus, - setSearchTerm, - setPage, - setActionLoading, - setError, - fetchEntries, - } = useWaitlistStore() - - // Local state for search input with debounce - const [searchInputValue, setSearchInputValue] = useState(searchTerm) - const searchTimeoutRef = useRef(null) - - // Enhanced error state - const [alertInfo, setAlertInfo] = useState<{ - type: AlertType - message: string - entryId?: string - }>({ type: null, message: '' }) - - // Auto-dismiss alert after 7 seconds - useEffect(() => { - if (alertInfo.type) { - const timer = setTimeout(() => { - setAlertInfo({ type: null, message: '' }) - }, 7000) - return () => clearTimeout(timer) - } - }, [alertInfo]) - - // Auth token for API calls - const [apiToken, setApiToken] = useState('') - const [authChecked, setAuthChecked] = useState(false) - - // Check authentication and redirect if needed - useEffect(() => { - // Check if user is authenticated - const token = sessionStorage.getItem('admin-auth-token') || '' - const isAuth = sessionStorage.getItem('admin-auth') === 'true' - - setApiToken(token) - - // If not authenticated, redirect to admin home page to show the login form - if (!isAuth || !token) { - logger.warn('Not authenticated, redirecting to admin page') - router.push('/admin') - return - } - - setAuthChecked(true) - }, [router]) - - // Get status from URL on initial load - only if authenticated - useEffect(() => { - if (!authChecked) return - - const urlStatus = searchParams.get('status') || 'all' - // Make sure it's a valid status - const validStatus = ['all', 'pending', 'approved', 'rejected'].includes(urlStatus) - ? urlStatus - : 'all' - - setStatus(validStatus) - }, [searchParams, setStatus, authChecked]) - - // Handle status filter change - const handleStatusChange = useCallback( - (newStatus: string) => { - if (newStatus !== status) { - setStatus(newStatus) - router.push(`?status=${newStatus}`) - } - }, - [status, setStatus, router] - ) - - // Handle search input change with debounce - const handleSearchChange = (e: React.ChangeEvent) => { - const value = e.target.value - setSearchInputValue(value) - - // Clear any existing timeout - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current) - } - - // Set a new timeout for debounce - searchTimeoutRef.current = setTimeout(() => { - setSearchTerm(value) - }, 500) // 500ms debounce - } - - // Handle individual approval - const handleApprove = async (email: string, id: string) => { - try { - setActionLoading(id) - setError(null) - setAlertInfo({ type: null, message: '' }) - - const response = await fetch('/api/admin/waitlist', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiToken}`, - }, - body: JSON.stringify({ email, action: 'approve' }), - }) - - const data = await response.json() - - if (!response.ok) { - // Handle specific error types - if (response.status === 429) { - setAlertInfo({ - type: 'rate-limit', - message: 'Rate limit exceeded. Please try again later.', - entryId: id, - }) - return - } else if (data.message?.includes('email') || data.message?.includes('resend')) { - setAlertInfo({ - type: 'email-error', - message: `Email delivery failed: ${data.message}`, - entryId: id, - }) - return - } else { - setAlertInfo({ - type: 'error', - message: data.message || 'Failed to approve user', - entryId: id, - }) - return - } - } - - if (!data.success) { - if (data.message?.includes('email') || data.message?.includes('resend')) { - setAlertInfo({ - type: 'email-error', - message: `Email delivery failed: ${data.message}`, - entryId: id, - }) - return - } else { - setAlertInfo({ - type: 'error', - message: data.message || 'Failed to approve user', - entryId: id, - }) - return - } - } - - // Success - don't refresh the table, just clear any errors - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to approve user' - setAlertInfo({ - type: 'error', - message: errorMessage, - entryId: id, - }) - logger.error('Error approving user:', error) - } finally { - setActionLoading(null) - } - } - - // Handle individual rejection - const handleReject = async (email: string, id: string) => { - try { - setActionLoading(id) - setError(null) - setAlertInfo({ type: null, message: '' }) - - const response = await fetch('/api/admin/waitlist', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiToken}`, - }, - body: JSON.stringify({ email, action: 'reject' }), - }) - - const data = await response.json() - - if (!response.ok || !data.success) { - setAlertInfo({ - type: 'error', - message: data.message || 'Failed to reject user', - entryId: id, - }) - return - } - - // Success - don't refresh the table - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to reject user' - setAlertInfo({ - type: 'error', - message: errorMessage, - entryId: id, - }) - logger.error('Error rejecting user:', error) - } finally { - setActionLoading(null) - } - } - - // Handle resending approval email - const handleResendApproval = async (email: string, id: string) => { - try { - setActionLoading(id) - setError(null) - setAlertInfo({ type: null, message: '' }) - - const response = await fetch('/api/admin/waitlist', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiToken}`, - }, - body: JSON.stringify({ email, action: 'resend' }), - }) - - const data = await response.json() - - if (!response.ok) { - // Handle specific error types - if (response.status === 429) { - setAlertInfo({ - type: 'rate-limit', - message: 'Rate limit exceeded. Please try again later.', - entryId: id, - }) - return - } else if (data.message?.includes('email') || data.message?.includes('resend')) { - setAlertInfo({ - type: 'email-error', - message: `Email delivery failed: ${data.message}`, - entryId: id, - }) - return - } else { - setAlertInfo({ - type: 'error', - message: data.message || 'Failed to resend approval email', - entryId: id, - }) - return - } - } - - if (!data.success) { - if (data.message?.includes('email') || data.message?.includes('resend')) { - setAlertInfo({ - type: 'email-error', - message: `Email delivery failed: ${data.message}`, - entryId: id, - }) - return - } else { - setAlertInfo({ - type: 'error', - message: data.message || 'Failed to resend approval email', - entryId: id, - }) - return - } - } - - // No UI update needed on success, just clear error state - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to resend approval email' - setAlertInfo({ - type: 'email-error', - message: errorMessage, - entryId: id, - }) - logger.error('Error resending approval email:', error) - } finally { - setActionLoading(null) - } - } - - // Navigation - const handleNextPage = () => setPage(page + 1) - const handlePrevPage = () => setPage(Math.max(page - 1, 1)) - const handleFirstPage = () => setPage(1) - const handleLastPage = () => { - const lastPage = Math.max(1, Math.ceil(totalEntries / 50)) - setPage(lastPage) - } - const handleRefresh = () => { - fetchEntries() - setAlertInfo({ type: null, message: '' }) - } - - // Format date helper - const formatDate = (date: Date) => { - const now = new Date() - const diffInMs = now.getTime() - date.getTime() - const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)) - - if (diffInDays < 1) return 'today' - if (diffInDays === 1) return 'yesterday' - if (diffInDays < 30) return `${diffInDays} days ago` - - return date.toLocaleDateString() - } - - // Get formatted timestamp for tooltips - const getDetailedTimeTooltip = (date: Date) => { - return date.toLocaleString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) - } - - // If not authenticated yet, show loading state - if (!authChecked) { - return ( -
- -
- ) - } - - return ( -
- {/* Filter bar - similar to logs.tsx */} -
-
-
- {/* Filter buttons */} -
- handleStatusChange('all')} - icon={} - label="All" - className={ - status === 'all' - ? 'bg-blue-100 text-blue-900 hover:bg-blue-200 hover:text-blue-900' - : '' - } - /> - handleStatusChange('pending')} - icon={} - label="Pending" - className={ - status === 'pending' - ? 'bg-amber-100 text-amber-900 hover:bg-amber-200 hover:text-amber-900' - : '' - } - /> - handleStatusChange('approved')} - icon={} - label="Approved" - className={ - status === 'approved' - ? 'bg-green-100 text-green-900 hover:bg-green-200 hover:text-green-900' - : '' - } - /> - handleStatusChange('rejected')} - icon={} - label="Rejected" - className={ - status === 'rejected' - ? 'bg-red-100 text-red-900 hover:bg-red-200 hover:text-red-900' - : '' - } - /> - handleStatusChange('signed_up')} - icon={} - label="Signed Up" - className={ - status === 'signed_up' - ? 'bg-purple-100 text-purple-900 hover:bg-purple-200 hover:text-purple-900' - : '' - } - /> -
-
-
-
- - {/* Search and refresh bar */} -
-
- - -
- -
- -
-
- - {/* Enhanced Alert system */} - {alertInfo.type && ( - - - - {alertInfo.type === 'email-error' - ? 'Email Delivery Failed' - : alertInfo.type === 'rate-limit' - ? 'Rate Limit Exceeded' - : 'Error'} - - - {alertInfo.message} - - - - )} - - {/* Original error alert - kept for backward compatibility */} - {error && !alertInfo.type && ( - - - - {error} - - - - )} - - {/* Loading skeleton */} - {loading ? ( -
-
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
- ) : filteredEntries.length === 0 ? ( -
-
- -
-

No entries found

-

- {searchTerm - ? 'No matching entries found with the current search term' - : `No ${status === 'all' ? '' : status} entries found in the waitlist.`} -

-
- ) : ( - <> - {/* Table */} -
- - - - Email - Joined - Status - Actions - - - - {filteredEntries.map((entry) => ( - - {entry.email} - - - - - {formatDate(entry.createdAt)} - - {getDetailedTimeTooltip(entry.createdAt)} - - - - - {/* Status badge */} -
- {entry.status === 'pending' && ( - - - Pending - - )} - {entry.status === 'approved' && ( - - - Approved - - )} - {entry.status === 'rejected' && ( - - - Rejected - - )} - {entry.status === 'signed_up' && ( - - - Signed Up - - )} -
-
- -
- {entry.status !== 'approved' && ( - - - - - - Approve user and send access email - - - )} - - {entry.status === 'approved' && ( - - - - - - - Resend approval email with sign-up link - - - - )} - - {entry.status !== 'rejected' && entry.status !== 'approved' && ( - - - - - - Reject user - - - )} - - - - - - - Email user in Gmail - - -
-
-
- ))} -
-
-
- - {/* Pagination */} - {!searchTerm && ( -
-
- - -
- - - Page {page} of {Math.ceil(totalEntries / 50) || 1} -  •  - {totalEntries} total entries - - -
- - -
-
- )} - - )} -
- ) -} diff --git a/sim/app/admin/waitlist/waitlist.tsx b/sim/app/admin/waitlist/waitlist.tsx new file mode 100644 index 00000000000..a557d89f6dc --- /dev/null +++ b/sim/app/admin/waitlist/waitlist.tsx @@ -0,0 +1,625 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { + AlertCircleIcon, + InfoIcon, + RotateCcwIcon, +} from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Logger } from '@/lib/logs/console-logger' +import { useWaitlistStore } from './stores/store' +import { FilterBar } from './components/filter-bar/filter-bar' +import { SearchBar } from './components/search-bar/search-bar' +import { WaitlistAlert } from './components/waitlist-alert/waitlist-alert' +import { Pagination } from './components/pagination/pagination' +import { BatchActions } from './components/batch-actions/batch-actions' +import { BatchResultsModal } from './components/batch-results-modal/batch-results-modal' +import { WaitlistTable as WaitlistDataTable } from './components/waitlist-table/waitlist-table' + +const logger = new Logger('WaitlistTable') + +type AlertType = 'error' | 'email-error' | 'rate-limit' | null + +export function WaitlistTable() { + const router = useRouter() + const searchParams = useSearchParams() + + const { + entries, + filteredEntries, + status, + searchTerm, + page, + totalEntries, + loading, + error, + actionLoading, + setStatus, + setSearchTerm, + setPage, + setActionLoading, + setError, + fetchEntries, + } = useWaitlistStore() + + // Enhanced error state + const [alertInfo, setAlertInfo] = useState<{ + type: AlertType + message: string + entryId?: string + }>({ type: null, message: '' }) + + // Auto-dismiss alert after 7 seconds + useEffect(() => { + if (alertInfo.type) { + const timer = setTimeout(() => { + setAlertInfo({ type: null, message: '' }) + }, 7000) + return () => clearTimeout(timer) + } + }, [alertInfo]) + + // Auth token for API calls + const [apiToken, setApiToken] = useState('') + const [authChecked, setAuthChecked] = useState(false) + + // Check authentication and redirect if needed + useEffect(() => { + // Check if user is authenticated + const token = sessionStorage.getItem('admin-auth-token') || '' + const isAuth = sessionStorage.getItem('admin-auth') === 'true' + + setApiToken(token) + + // If not authenticated, redirect to admin home page to show the login form + if (!isAuth || !token) { + logger.warn('Not authenticated, redirecting to admin page') + router.push('/admin') + return + } + + setAuthChecked(true) + }, [router]) + + // Get status from URL on initial load - only if authenticated + useEffect(() => { + if (!authChecked) return + + const urlStatus = searchParams.get('status') || 'all' + // Make sure it's a valid status + const validStatus = ['all', 'pending', 'approved', 'rejected'].includes(urlStatus) + ? urlStatus + : 'all' + + setStatus(validStatus) + }, [searchParams, setStatus, authChecked]) + + // Handle status filter change + const handleStatusChange = useCallback( + (newStatus: string) => { + if (newStatus !== status) { + setStatus(newStatus) + router.push(`?status=${newStatus}`) + } + }, + [status, setStatus, router] + ) + + // Handle individual approval + const handleApprove = async (email: string, id: string) => { + try { + setActionLoading(id) + setError(null) + setAlertInfo({ type: null, message: '' }) + + const response = await fetch('/api/admin/waitlist', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiToken}`, + }, + body: JSON.stringify({ email, action: 'approve' }), + }) + + const data = await response.json() + + if (!response.ok) { + // Handle specific error types + if (response.status === 429) { + setAlertInfo({ + type: 'rate-limit', + message: 'Rate limit exceeded. Please try again later.', + entryId: id, + }) + return + } else if (data.message?.includes('email') || data.message?.includes('resend')) { + setAlertInfo({ + type: 'email-error', + message: `Email delivery failed: ${data.message}`, + entryId: id, + }) + return + } else { + setAlertInfo({ + type: 'error', + message: data.message || 'Failed to approve user', + entryId: id, + }) + return + } + } + + if (!data.success) { + if (data.message?.includes('email') || data.message?.includes('resend')) { + setAlertInfo({ + type: 'email-error', + message: `Email delivery failed: ${data.message}`, + entryId: id, + }) + return + } else { + setAlertInfo({ + type: 'error', + message: data.message || 'Failed to approve user', + entryId: id, + }) + return + } + } + + // Success - don't refresh the table, just clear any errors + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to approve user' + setAlertInfo({ + type: 'error', + message: errorMessage, + entryId: id, + }) + logger.error('Error approving user:', error) + } finally { + setActionLoading(null) + } + } + + // Handle individual rejection + const handleReject = async (email: string, id: string) => { + try { + setActionLoading(id) + setError(null) + setAlertInfo({ type: null, message: '' }) + + const response = await fetch('/api/admin/waitlist', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiToken}`, + }, + body: JSON.stringify({ email, action: 'reject' }), + }) + + const data = await response.json() + + if (!response.ok || !data.success) { + setAlertInfo({ + type: 'error', + message: data.message || 'Failed to reject user', + entryId: id, + }) + return + } + + // Success - don't refresh the table + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to reject user' + setAlertInfo({ + type: 'error', + message: errorMessage, + entryId: id, + }) + logger.error('Error rejecting user:', error) + } finally { + setActionLoading(null) + } + } + + // Handle resending approval email + const handleResendApproval = async (email: string, id: string) => { + try { + setActionLoading(id) + setError(null) + setAlertInfo({ type: null, message: '' }) + + const response = await fetch('/api/admin/waitlist', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiToken}`, + }, + body: JSON.stringify({ email, action: 'resend' }), + }) + + const data = await response.json() + + if (!response.ok) { + // Handle specific error types + if (response.status === 429) { + setAlertInfo({ + type: 'rate-limit', + message: 'Rate limit exceeded. Please try again later.', + entryId: id, + }) + return + } else if (data.message?.includes('email') || data.message?.includes('resend')) { + setAlertInfo({ + type: 'email-error', + message: `Email delivery failed: ${data.message}`, + entryId: id, + }) + return + } else { + setAlertInfo({ + type: 'error', + message: data.message || 'Failed to resend approval email', + entryId: id, + }) + return + } + } + + if (!data.success) { + if (data.message?.includes('email') || data.message?.includes('resend')) { + setAlertInfo({ + type: 'email-error', + message: `Email delivery failed: ${data.message}`, + entryId: id, + }) + return + } else { + setAlertInfo({ + type: 'error', + message: data.message || 'Failed to resend approval email', + entryId: id, + }) + return + } + } + + // No UI update needed on success, just clear error state + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to resend approval email' + setAlertInfo({ + type: 'email-error', + message: errorMessage, + entryId: id, + }) + logger.error('Error resending approval email:', error) + } finally { + setActionLoading(null) + } + } + + // Navigation + const handleNextPage = () => setPage(page + 1) + const handlePrevPage = () => setPage(Math.max(page - 1, 1)) + const handleFirstPage = () => setPage(1) + const handleLastPage = () => { + const lastPage = Math.max(1, Math.ceil(totalEntries / 50)) + setPage(lastPage) + } + const handleRefresh = () => { + fetchEntries() + setAlertInfo({ type: null, message: '' }) + } + + // Format date helper + const formatDate = (date: Date) => { + const now = new Date() + const diffInMs = now.getTime() - date.getTime() + const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)) + + if (diffInDays < 1) return 'today' + if (diffInDays === 1) return 'yesterday' + if (diffInDays < 30) return `${diffInDays} days ago` + + return date.toLocaleDateString() + } + + // Get formatted timestamp for tooltips + const getDetailedTimeTooltip = (date: Date) => { + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + } + + // State for selected emails (for batch operations) + const [selectedEmails, setSelectedEmails] = useState>({}) + const [showBatchDialog, setShowBatchDialog] = useState(false) + const [batchActionLoading, setBatchActionLoading] = useState(false) + const [batchResults, setBatchResults] = useState | null>(null) + + // Helper to check if any emails are selected + const hasSelectedEmails = Object.values(selectedEmails).some(Boolean) + + // Count of selected emails + const selectedEmailsCount = Object.values(selectedEmails).filter(Boolean).length + + // Toggle selection of a single email + const toggleEmailSelection = (email: string) => { + setSelectedEmails(prev => ({ + ...prev, + [email]: !prev[email] + })) + } + + // Clear all selections + const clearSelections = () => { + setSelectedEmails({}) + } + + // Select/deselect all visible emails + const toggleSelectAll = () => { + if (filteredEntries.some(entry => selectedEmails[entry.email])) { + // If any are selected, deselect all + const newSelection = { ...selectedEmails } + filteredEntries.forEach(entry => { + newSelection[entry.email] = false + }) + setSelectedEmails(newSelection) + } else { + // Select all visible entries + const newSelection = { ...selectedEmails } + filteredEntries.forEach(entry => { + newSelection[entry.email] = true + }) + setSelectedEmails(newSelection) + } + } + + // Handle batch approval + const handleBatchApprove = async () => { + try { + setBatchActionLoading(true) + setBatchResults(null) + setAlertInfo({ type: null, message: '' }) + + // Get list of selected emails + const emails = Object.entries(selectedEmails) + .filter(([_, isSelected]) => isSelected) + .map(([email]) => email) + + if (emails.length === 0) { + setAlertInfo({ + type: 'error', + message: 'No emails selected for batch approval', + }) + return + } + + const response = await fetch('/api/admin/waitlist', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiToken}`, + }, + body: JSON.stringify({ emails, action: 'batchApprove' }), + }) + + const data = await response.json() + + if (!response.ok) { + // Handle specific error types + if (response.status === 429) { + setAlertInfo({ + type: 'rate-limit', + message: 'Rate limit exceeded. Please try again later with fewer emails.', + }) + setBatchResults(data.results || null) + return + } else if (data.message?.includes('email') || data.message?.includes('resend')) { + setAlertInfo({ + type: 'email-error', + message: `Email delivery failed: ${data.message}`, + }) + setBatchResults(data.results || null) + return + } else { + setAlertInfo({ + type: 'error', + message: data.message || 'Failed to approve users', + }) + setBatchResults(data.results || null) + return + } + } + + if (!data.success) { + setAlertInfo({ + type: 'error', + message: data.message || 'Failed to approve some or all users', + }) + setBatchResults(data.results || null) + return + } + + // Success + setShowBatchDialog(true) + setBatchResults(data.results || []) + + // Clear selections for successfully approved emails + if (data.results && Array.isArray(data.results)) { + const successfulEmails = data.results + .filter((result: { success: boolean }) => result.success) + .map((result: { email: string }) => result.email) + + if (successfulEmails.length > 0) { + const newSelection = { ...selectedEmails } + successfulEmails.forEach((email: string) => { + newSelection[email] = false + }) + setSelectedEmails(newSelection) + + // Refresh the entries to show updated statuses + fetchEntries() + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to approve users' + setAlertInfo({ + type: 'error', + message: errorMessage, + }) + logger.error('Error batch approving users:', error) + } finally { + setBatchActionLoading(false) + } + } + + // If not authenticated yet, show loading state + if (!authChecked) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Top bar with filters, search and refresh */} +
+ {/* Filter buttons in a single row */} + + + {/* Search and refresh aligned to the right */} +
+ + +
+
+ + {/* Enhanced Alert system */} + setAlertInfo({ type: null, message: '' })} + onRefresh={alertInfo.type === 'error' ? handleRefresh : undefined} + /> + + {/* Original error alert - kept for backward compatibility */} + {error && !alertInfo.type && ( + + + + {error} + + + + )} + + {/* Select All row - only shown when not in approved view and entries exist */} + {status !== 'approved' && filteredEntries.length > 0 && !loading && ( + 0} + someSelected={filteredEntries.some(entry => selectedEmails[entry.email])} + /> + )} + + {/* Loading skeleton */} + {loading ? ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ) : filteredEntries.length === 0 ? ( +
+
+ +
+

No entries found

+

+ {searchTerm + ? 'No matching entries found with the current search term' + : `No ${status === 'all' ? '' : status} entries found in the waitlist.`} +

+
+ ) : ( + <> + {/* Table */} + + + {/* Pagination */} + {!searchTerm && ( + + )} + + )} + + {/* Batch results dialog */} + { + setShowBatchDialog(false) + setBatchResults(null) + }} + /> +
+ ) +} diff --git a/sim/app/api/admin/waitlist/route.ts b/sim/app/api/admin/waitlist/route.ts index 7fa9df6de85..0cbd0e6d45f 100644 --- a/sim/app/api/admin/waitlist/route.ts +++ b/sim/app/api/admin/waitlist/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { Logger } from '@/lib/logs/console-logger' import { approveWaitlistUser, + approveBatchWaitlistUsers, getWaitlistEntries, rejectWaitlistUser, resendApprovalEmail @@ -24,6 +25,12 @@ const actionSchema = z.object({ action: z.enum(['approve', 'reject', 'resend']), }) +// Schema for batch approval request +const batchActionSchema = z.object({ + emails: z.array(z.string().email()).min(1).max(100), + action: z.literal('batchApprove'), +}) + // Admin password from environment variables const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '' @@ -149,265 +156,321 @@ export async function POST(request: NextRequest) { // Parse request body const body = await request.json() - // Validate request - const validatedData = actionSchema.safeParse(body) - - if (!validatedData.success) { - return NextResponse.json( - { - success: false, - message: 'Invalid request', - errors: validatedData.error.format(), - }, - { status: 400 } - ) - } - - const { email, action } = validatedData.data + // Check if it's a batch action + if (body.action === 'batchApprove' && Array.isArray(body.emails)) { + // Validate batch request + const validatedData = batchActionSchema.safeParse(body) - let result: any + if (!validatedData.success) { + return NextResponse.json( + { + success: false, + message: 'Invalid batch request', + errors: validatedData.error.format(), + }, + { status: 400 } + ) + } - // Perform the requested action - if (action === 'approve') { + const { emails } = validatedData.data + + logger.info(`Processing batch approval for ${emails.length} emails`) + try { - // Need to handle email errors specially to prevent approving users when email fails - result = await approveWaitlistUser(email) - - // First check for email delivery errors from Resend - if (!result.success && result?.emailError) { - logger.error('Email delivery error:', result.emailError) - - // Check if it's a rate limit error - if (result.rateLimited || detectResendRateLimitError(result.emailError)) { - return NextResponse.json( - { - success: false, - message: 'Rate limit exceeded for email sending. User was NOT approved.', - rateLimited: true, - emailError: true - }, - { status: 429 } - ) - } - - return NextResponse.json( - { - success: false, - message: `Email delivery failed: ${result.message || 'Unknown email error'}. User was NOT approved.`, - emailError: true - }, - { status: 500 } - ) - } + const result = await approveBatchWaitlistUsers(emails) // Check for rate limiting - if (!result.success && result?.rateLimited) { + if (!result.success && result.rateLimited) { logger.warn('Rate limit reached for email sending') return NextResponse.json( { success: false, - message: 'Rate limit exceeded for email sending. User was NOT approved.', - rateLimited: true + message: 'Rate limit exceeded for email sending. Users were NOT approved.', + rateLimited: true, + results: result.results }, { status: 429 } ) } - // General failure - if (!result.success) { - return NextResponse.json( - { - success: false, - message: result.message || 'Failed to approve user' - }, - { status: 400 } - ) - } + // Return the result, even if partially successful + return NextResponse.json({ + success: result.success, + message: result.message, + results: result.results + }) } catch (error) { - logger.error('Error approving waitlist user:', error) - - // Check if it's the JWT_SECRET missing error - if (error instanceof Error && error.message.includes('JWT_SECRET')) { - return NextResponse.json( - { - success: false, - message: - 'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.', - }, - { status: 500 } - ) - } - - // Handle Resend API errors specifically - if (error instanceof Error && - (error.message.includes('email') || - error.message.includes('resend'))) { - - // Handle rate limiting specifically - if (detectResendRateLimitError(error)) { - return NextResponse.json( - { - success: false, - message: 'Rate limit exceeded for email sending. User was NOT approved.', - rateLimited: true, - emailError: true - }, - { status: 429 } - ) - } - - return NextResponse.json( - { - success: false, - message: `Email delivery failed: ${error.message}. User was NOT approved.`, - emailError: true - }, - { status: 500 } - ) - } - + logger.error('Error in batch approval:', error) return NextResponse.json( { success: false, - message: error instanceof Error ? error.message : 'Failed to approve user', + message: error instanceof Error ? error.message : 'Failed to process batch approval', }, { status: 500 } ) } - } else if (action === 'reject') { - try { - result = await rejectWaitlistUser(email) - } catch (error) { - logger.error('Error rejecting waitlist user:', error) + } else { + // Handle individual actions + // Validate request + const validatedData = actionSchema.safeParse(body) + + if (!validatedData.success) { return NextResponse.json( { success: false, - message: error instanceof Error ? error.message : 'Failed to reject user', + message: 'Invalid request', + errors: validatedData.error.format(), }, - { status: 500 } + { status: 400 } ) } - } else if (action === 'resend') { - try { - result = await resendApprovalEmail(email) - - // First check for email delivery errors from Resend - if (!result.success && result?.emailError) { - logger.error('Email delivery error:', result.emailError) + + const { email, action } = validatedData.data + + let result: any + + // Perform the requested action + if (action === 'approve') { + try { + // Need to handle email errors specially to prevent approving users when email fails + result = await approveWaitlistUser(email) - // Check if it's a rate limit error - if (result.rateLimited || detectResendRateLimitError(result.emailError)) { + // First check for email delivery errors from Resend + if (!result.success && result?.emailError) { + logger.error('Email delivery error:', result.emailError) + + // Check if it's a rate limit error + if (result.rateLimited || detectResendRateLimitError(result.emailError)) { + return NextResponse.json( + { + success: false, + message: 'Rate limit exceeded for email sending. User was NOT approved.', + rateLimited: true, + emailError: true + }, + { status: 429 } + ) + } + return NextResponse.json( { success: false, - message: 'Rate limit exceeded for email sending.', - rateLimited: true, + message: `Email delivery failed: ${result.message || 'Unknown email error'}. User was NOT approved.`, emailError: true }, + { status: 500 } + ) + } + + // Check for rate limiting + if (!result.success && result?.rateLimited) { + logger.warn('Rate limit reached for email sending') + return NextResponse.json( + { + success: false, + message: 'Rate limit exceeded for email sending. User was NOT approved.', + rateLimited: true + }, { status: 429 } ) } + // General failure + if (!result.success) { + return NextResponse.json( + { + success: false, + message: result.message || 'Failed to approve user' + }, + { status: 400 } + ) + } + } catch (error) { + logger.error('Error approving waitlist user:', error) + + // Check if it's the JWT_SECRET missing error + if (error instanceof Error && error.message.includes('JWT_SECRET')) { + return NextResponse.json( + { + success: false, + message: + 'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.', + }, + { status: 500 } + ) + } + + // Handle Resend API errors specifically + if (error instanceof Error && + (error.message.includes('email') || + error.message.includes('resend'))) { + + // Handle rate limiting specifically + if (detectResendRateLimitError(error)) { + return NextResponse.json( + { + success: false, + message: 'Rate limit exceeded for email sending. User was NOT approved.', + rateLimited: true, + emailError: true + }, + { status: 429 } + ) + } + + return NextResponse.json( + { + success: false, + message: `Email delivery failed: ${error.message}. User was NOT approved.`, + emailError: true + }, + { status: 500 } + ) + } + return NextResponse.json( { success: false, - message: `Email delivery failed: ${result.message || 'Unknown email error'}`, - emailError: true + message: error instanceof Error ? error.message : 'Failed to approve user', }, { status: 500 } ) } - - // Check for rate limiting - if (!result.success && result?.rateLimited) { - logger.warn('Rate limit reached for email sending') - return NextResponse.json( - { - success: false, - message: 'Rate limit exceeded for email sending', - rateLimited: true - }, - { status: 429 } - ) - } - - // General failure - if (!result.success) { + } else if (action === 'reject') { + try { + result = await rejectWaitlistUser(email) + } catch (error) { + logger.error('Error rejecting waitlist user:', error) return NextResponse.json( { success: false, - message: result.message || 'Failed to resend approval email' - }, - { status: 400 } - ) - } - } catch (error) { - logger.error('Error resending approval email:', error) - - // Check if it's the JWT_SECRET missing error - if (error instanceof Error && error.message.includes('JWT_SECRET')) { - return NextResponse.json( - { - success: false, - message: - 'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.', + message: error instanceof Error ? error.message : 'Failed to reject user', }, { status: 500 } ) } - - // Handle Resend API errors specifically - if (error instanceof Error && - (error.message.includes('email') || - error.message.includes('resend'))) { + } else if (action === 'resend') { + try { + result = await resendApprovalEmail(email) - // Handle rate limiting specifically - if (detectResendRateLimitError(error)) { + // First check for email delivery errors from Resend + if (!result.success && result?.emailError) { + logger.error('Email delivery error:', result.emailError) + + // Check if it's a rate limit error + if (result.rateLimited || detectResendRateLimitError(result.emailError)) { + return NextResponse.json( + { + success: false, + message: 'Rate limit exceeded for email sending.', + rateLimited: true, + emailError: true + }, + { status: 429 } + ) + } + return NextResponse.json( { success: false, - message: 'Rate limit exceeded for email sending', - rateLimited: true, + message: `Email delivery failed: ${result.message || 'Unknown email error'}`, emailError: true }, + { status: 500 } + ) + } + + // Check for rate limiting + if (!result.success && result?.rateLimited) { + logger.warn('Rate limit reached for email sending') + return NextResponse.json( + { + success: false, + message: 'Rate limit exceeded for email sending', + rateLimited: true + }, { status: 429 } ) } + // General failure + if (!result.success) { + return NextResponse.json( + { + success: false, + message: result.message || 'Failed to resend approval email' + }, + { status: 400 } + ) + } + } catch (error) { + logger.error('Error resending approval email:', error) + + // Check if it's the JWT_SECRET missing error + if (error instanceof Error && error.message.includes('JWT_SECRET')) { + return NextResponse.json( + { + success: false, + message: + 'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.', + }, + { status: 500 } + ) + } + + // Handle Resend API errors specifically + if (error instanceof Error && + (error.message.includes('email') || + error.message.includes('resend'))) { + + // Handle rate limiting specifically + if (detectResendRateLimitError(error)) { + return NextResponse.json( + { + success: false, + message: 'Rate limit exceeded for email sending', + rateLimited: true, + emailError: true + }, + { status: 429 } + ) + } + + return NextResponse.json( + { + success: false, + message: `Email delivery failed: ${error.message}`, + emailError: true + }, + { status: 500 } + ) + } + return NextResponse.json( { success: false, - message: `Email delivery failed: ${error.message}`, - emailError: true + message: error instanceof Error ? error.message : 'Failed to resend approval email', }, { status: 500 } ) } - + } + + if (!result || !result.success) { return NextResponse.json( { success: false, - message: error instanceof Error ? error.message : 'Failed to resend approval email', + message: result?.message || 'Failed to perform action', }, - { status: 500 } + { status: 400 } ) } - } - if (!result || !result.success) { - return NextResponse.json( - { - success: false, - message: result?.message || 'Failed to perform action', - }, - { status: 400 } - ) + return NextResponse.json({ + success: true, + message: result.message, + }) } - - return NextResponse.json({ - success: true, - message: result.message, - }) } catch (error) { logger.error('Admin waitlist API error:', error) diff --git a/sim/lib/mailer.ts b/sim/lib/mailer.ts index f079fb08f71..640ae66a338 100644 --- a/sim/lib/mailer.ts +++ b/sim/lib/mailer.ts @@ -8,12 +8,23 @@ interface EmailOptions { from?: string } +interface BatchEmailOptions { + emails: EmailOptions[] +} + interface SendEmailResult { success: boolean message: string data?: any } +interface BatchSendEmailResult { + success: boolean + message: string + results: SendEmailResult[] + data?: any +} + const logger = createLogger('Mailer') const resendApiKey = process.env.RESEND_API_KEY @@ -72,3 +83,188 @@ export async function sendEmail({ } } } + +export async function sendBatchEmails({ + emails, +}: BatchEmailOptions): Promise { + try { + const senderEmail = 'noreply@simstudio.ai' + const results: SendEmailResult[] = [] + + if (!resend) { + logger.info('Batch emails not sent (Resend not configured):', { + emailCount: emails.length, + }) + + // Create mock results for each email + emails.forEach(() => { + results.push({ + success: true, + message: 'Email logging successful (Resend not configured)', + data: { id: 'mock-email-id' }, + }) + }) + + return { + success: true, + message: 'Batch email logging successful (Resend not configured)', + results, + data: { ids: Array(emails.length).fill('mock-email-id') }, + } + } + + // Prepare emails for batch sending + const batchEmails = emails.map(email => ({ + from: `Sim Studio <${email.from || senderEmail}>`, + to: email.to, + subject: email.subject, + html: email.html, + })) + + // Send batch emails (maximum 100 per batch as per Resend API limits) + // Process in chunks of 50 to be safe + const BATCH_SIZE = 50 + let allSuccessful = true + + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + + let rateDelay = 500 + + for (let i = 0; i < batchEmails.length; i += BATCH_SIZE) { + if (i > 0) { + logger.info(`Rate limit protection: Waiting ${rateDelay}ms before sending next batch`) + await delay(rateDelay) + } + + const batch = batchEmails.slice(i, i + BATCH_SIZE) + + try { + logger.info(`Sending batch ${Math.floor(i/BATCH_SIZE) + 1} of ${Math.ceil(batchEmails.length/BATCH_SIZE)} (${batch.length} emails)`) + const response = await resend.batch.send(batch) + + if (response.error) { + logger.error('Resend batch API error:', response.error) + + // Add failure results for this batch + batch.forEach(() => { + results.push({ + success: false, + message: response.error?.message || 'Failed to send batch email', + }) + }) + + allSuccessful = false + } else if (response.data) { + if (Array.isArray(response.data)) { + response.data.forEach((item: { id: string }) => { + results.push({ + success: true, + message: 'Email sent successfully', + data: item, + }) + }) + } else { + logger.info('Resend batch API returned unexpected format, assuming success') + batch.forEach((_, index) => { + results.push({ + success: true, + message: 'Email sent successfully', + data: { id: `batch-${i}-item-${index}` }, + }) + }) + } + } + } catch (error) { + logger.error('Error sending batch emails:', error) + + // Check if it's a rate limit error + if (error instanceof Error && + (error.message.toLowerCase().includes('rate') || + error.message.toLowerCase().includes('too many') || + error.message.toLowerCase().includes('429'))) { + logger.warn('Rate limit exceeded, increasing delay and retrying...') + + // Wait a bit longer and try again with this batch + await delay(rateDelay * 5) + + try { + logger.info(`Retrying batch ${Math.floor(i/BATCH_SIZE) + 1} with longer delay`) + const retryResponse = await resend.batch.send(batch) + + if (retryResponse.error) { + logger.error('Retry failed with error:', retryResponse.error) + + batch.forEach(() => { + results.push({ + success: false, + message: retryResponse.error?.message || 'Failed to send batch email after retry', + }) + }) + + allSuccessful = false + } else if (retryResponse.data) { + if (Array.isArray(retryResponse.data)) { + retryResponse.data.forEach((item: { id: string }) => { + results.push({ + success: true, + message: 'Email sent successfully on retry', + data: item, + }) + }) + } else { + batch.forEach((_, index) => { + results.push({ + success: true, + message: 'Email sent successfully on retry', + data: { id: `retry-batch-${i}-item-${index}` }, + }) + }) + } + + // Increase the standard delay since we hit a rate limit + logger.info('Increasing delay between batches after rate limit hit') + rateDelay = rateDelay * 2 + } + } catch (retryError) { + logger.error('Retry also failed:', retryError) + + batch.forEach(() => { + results.push({ + success: false, + message: retryError instanceof Error ? retryError.message : 'Failed to send email even after retry', + }) + }) + + allSuccessful = false + } + } else { + // Non-rate limit error + batch.forEach(() => { + results.push({ + success: false, + message: error instanceof Error ? error.message : 'Failed to send batch email', + }) + }) + + allSuccessful = false + } + } + } + + return { + success: allSuccessful, + message: allSuccessful + ? 'All batch emails sent successfully' + : 'Some batch emails failed to send', + results, + data: { count: results.filter(r => r.success).length }, + } + } catch (error) { + logger.error('Error in batch email sending:', error) + return { + success: false, + message: 'Failed to send batch emails', + results: [], + } + } +} diff --git a/sim/lib/waitlist/service.ts b/sim/lib/waitlist/service.ts index 6d9802a7f53..da74854eaa1 100644 --- a/sim/lib/waitlist/service.ts +++ b/sim/lib/waitlist/service.ts @@ -1,11 +1,11 @@ -import { and, count, desc, eq, like, or, SQL } from 'drizzle-orm' +import { and, count, desc, eq, like, or, SQL, inArray } from 'drizzle-orm' import { nanoid } from 'nanoid' import { getEmailSubject, renderWaitlistApprovalEmail, renderWaitlistConfirmationEmail, } from '@/components/emails/render-email' -import { sendEmail } from '@/lib/mailer' +import { sendEmail, sendBatchEmails } from '@/lib/mailer' import { createToken, verifyToken } from '@/lib/waitlist/token' import { db } from '@/db' import { waitlist } from '@/db/schema' @@ -491,3 +491,159 @@ export async function resendApprovalEmail( } } } + +// Approve multiple users from the waitlist and send approval emails in batches +export async function approveBatchWaitlistUsers( + emails: string[] +): Promise<{ + success: boolean + message: string + results: Array<{ email: string, success: boolean, message: string }> + emailErrors?: any + rateLimited?: boolean +}> { + try { + if (!emails || emails.length === 0) { + return { + success: false, + message: 'No emails provided for batch approval', + results: [], + } + } + + // Fetch all users from the waitlist that match the emails + const normalizedEmails = emails.map(email => email.trim().toLowerCase()) + + const users = await db + .select() + .from(waitlist) + .where( + and( + inArray(waitlist.email, normalizedEmails), + // Only select users who aren't already approved + or( + eq(waitlist.status, 'pending'), + eq(waitlist.status, 'rejected') + ) + ) + ) + + if (users.length === 0) { + return { + success: false, + message: 'No valid users found for approval', + results: emails.map(email => ({ + email, + success: false, + message: 'User not found or already approved', + })), + } + } + + // Create email options for each user + const emailOptions = await Promise.all( + users.map(async user => { + // Create a special signup token + const token = await createToken({ + email: user.email, + type: 'waitlist-approval', + expiresIn: '7d', + }) + + // Generate signup link with token + const signupLink = `${process.env.NEXT_PUBLIC_APP_URL}/signup?token=${token}` + + // Generate email HTML + const emailHtml = await renderWaitlistApprovalEmail(user.email, signupLink) + const subject = getEmailSubject('waitlist-approval') + + return { + to: user.email, + subject, + html: emailHtml, + } + }) + ) + + // Send batch emails + const emailResults = await sendBatchEmails({ emails: emailOptions }) + + // Process results and update database + const results = users.map((user, index) => { + const emailResult = emailResults.results[index] + + if (emailResult?.success) { + // Update user status to approved in database + return { + email: user.email, + success: true, + message: 'User approved and email sent successfully', + data: emailResult.data, + } + } else { + return { + email: user.email, + success: false, + message: emailResult?.message || 'Failed to send approval email', + error: emailResult, + } + } + }) + + // Update approved users in the database + const successfulEmails = results + .filter(result => result.success) + .map(result => result.email) + + if (successfulEmails.length > 0) { + await db + .update(waitlist) + .set({ + status: 'approved', + updatedAt: new Date(), + }) + .where( + and( + inArray(waitlist.email, successfulEmails), + // Only update users who aren't already approved + or( + eq(waitlist.status, 'pending'), + eq(waitlist.status, 'rejected') + ) + ) + ) + } + + // Check if any rate limit errors occurred + const rateLimitError = emailResults.results.some( + (result: { message?: string }) => + result.message?.toLowerCase().includes('rate') || + result.message?.toLowerCase().includes('too many') || + result.message?.toLowerCase().includes('limit') + ) + + return { + success: successfulEmails.length > 0, + message: successfulEmails.length === users.length + ? 'All users approved successfully' + : successfulEmails.length > 0 + ? 'Some users approved successfully' + : 'Failed to approve any users', + results: results.map(({ email, success, message }: { email: string; success: boolean; message: string }) => + ({ email, success, message })), + emailErrors: emailResults.results.some((r: { success: boolean }) => !r.success), + rateLimited: rateLimitError, + } + } catch (error) { + console.error('Error approving batch waitlist users:', error) + return { + success: false, + message: 'An error occurred while approving users', + results: emails.map(email => ({ + email, + success: false, + message: 'Operation failed due to server error', + })), + } + } +}