mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
improvement(admin-waitlist): removed bulk, added reliability and ease of approval
This commit is contained in:
@@ -26,26 +26,18 @@ interface WaitlistState {
|
||||
page: number
|
||||
totalEntries: number
|
||||
|
||||
// Selection
|
||||
selectedIds: Set<string>
|
||||
|
||||
// Loading states
|
||||
actionLoading: string | null
|
||||
bulkActionLoading: boolean
|
||||
|
||||
// Actions
|
||||
setStatus: (status: string) => void
|
||||
setSearchTerm: (searchTerm: string) => void
|
||||
setPage: (page: number) => void
|
||||
toggleSelectEntry: (id: string) => void
|
||||
selectAll: () => void
|
||||
deselectAll: () => void
|
||||
fetchEntries: () => Promise<void>
|
||||
setEntries: (entries: WaitlistEntry[]) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
setActionLoading: (id: string | null) => void
|
||||
setBulkActionLoading: (loading: boolean) => void
|
||||
}
|
||||
|
||||
export const useWaitlistStore = create<WaitlistState>((set, get) => ({
|
||||
@@ -63,12 +55,8 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
|
||||
page: 1,
|
||||
totalEntries: 0,
|
||||
|
||||
// Selection
|
||||
selectedIds: new Set<string>(),
|
||||
|
||||
// Loading states
|
||||
actionLoading: null,
|
||||
bulkActionLoading: false,
|
||||
|
||||
// Filter actions
|
||||
setStatus: (status) => {
|
||||
@@ -77,7 +65,6 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
|
||||
status,
|
||||
page: 1,
|
||||
searchTerm: '',
|
||||
selectedIds: new Set(),
|
||||
loading: true,
|
||||
})
|
||||
get().fetchEntries()
|
||||
@@ -93,26 +80,6 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
|
||||
get().fetchEntries()
|
||||
},
|
||||
|
||||
// Selection actions
|
||||
toggleSelectEntry: (id) => {
|
||||
const newSelectedIds = new Set(get().selectedIds)
|
||||
if (newSelectedIds.has(id)) {
|
||||
newSelectedIds.delete(id)
|
||||
} else {
|
||||
newSelectedIds.add(id)
|
||||
}
|
||||
set({ selectedIds: newSelectedIds })
|
||||
},
|
||||
|
||||
selectAll: () => {
|
||||
const allIds = get().filteredEntries.map((entry) => entry.id)
|
||||
set({ selectedIds: new Set(allIds) })
|
||||
},
|
||||
|
||||
deselectAll: () => {
|
||||
set({ selectedIds: new Set() })
|
||||
},
|
||||
|
||||
// Data actions
|
||||
setEntries: (entries) => {
|
||||
set({
|
||||
@@ -126,7 +93,6 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setActionLoading: (id) => set({ actionLoading: id }),
|
||||
setBulkActionLoading: (loading) => set({ bulkActionLoading: loading }),
|
||||
|
||||
// Fetch data
|
||||
fetchEntries: async () => {
|
||||
@@ -152,7 +118,19 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error ${response.status}: ${response.statusText}`)
|
||||
let errorMessage = `Error ${response.status}: ${response.statusText}`
|
||||
|
||||
// Try to parse the error message from the response body
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
if (errorData.message) {
|
||||
errorMessage = errorData.message
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse error response:', parseError)
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@@ -5,18 +5,20 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckIcon,
|
||||
CheckSquareIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
InfoIcon,
|
||||
MailIcon,
|
||||
RotateCcwIcon,
|
||||
SearchIcon,
|
||||
SquareIcon,
|
||||
UserCheckIcon,
|
||||
UserIcon,
|
||||
UserXIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
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'
|
||||
@@ -43,6 +45,9 @@ interface FilterButtonProps {
|
||||
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) => (
|
||||
<Button
|
||||
@@ -70,17 +75,11 @@ export function WaitlistTable() {
|
||||
totalEntries,
|
||||
loading,
|
||||
error,
|
||||
selectedIds,
|
||||
actionLoading,
|
||||
bulkActionLoading,
|
||||
setStatus,
|
||||
setSearchTerm,
|
||||
setPage,
|
||||
toggleSelectEntry,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
setActionLoading,
|
||||
setBulkActionLoading,
|
||||
setError,
|
||||
fetchEntries,
|
||||
} = useWaitlistStore()
|
||||
@@ -89,6 +88,23 @@ export function WaitlistTable() {
|
||||
const [searchInputValue, setSearchInputValue] = useState(searchTerm)
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(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)
|
||||
@@ -151,20 +167,12 @@ export function WaitlistTable() {
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Toggle selection of all entries
|
||||
const handleToggleSelectAll = () => {
|
||||
if (selectedIds.size === filteredEntries.length) {
|
||||
deselectAll()
|
||||
} else {
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
// 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',
|
||||
@@ -177,14 +185,58 @@ export function WaitlistTable() {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Failed to approve user')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the data
|
||||
fetchEntries()
|
||||
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) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to approve user')
|
||||
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)
|
||||
@@ -196,6 +248,7 @@ export function WaitlistTable() {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
@@ -209,13 +262,22 @@ export function WaitlistTable() {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Failed to reject user')
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to reject user',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh the data
|
||||
fetchEntries()
|
||||
// Success - don't refresh the table
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to reject user')
|
||||
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)
|
||||
@@ -227,6 +289,7 @@ export function WaitlistTable() {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
@@ -239,150 +302,77 @@ export function WaitlistTable() {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Failed to resend approval email')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the data
|
||||
fetchEntries()
|
||||
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) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to resend approval email')
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bulk approval
|
||||
const handleBulkApprove = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
|
||||
setBulkActionLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const selectedEmails = filteredEntries
|
||||
.filter((entry) => selectedIds.has(entry.id))
|
||||
.map((entry) => entry.email)
|
||||
|
||||
const response = await fetch('/api/admin/waitlist/bulk', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
emails: selectedEmails,
|
||||
action: 'approve',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Failed to approve selected users')
|
||||
}
|
||||
|
||||
// Refresh data
|
||||
fetchEntries()
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to approve selected users')
|
||||
logger.error('Error approving users:', error)
|
||||
} finally {
|
||||
setBulkActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bulk rejection
|
||||
const handleBulkReject = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
|
||||
setBulkActionLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const selectedEmails = filteredEntries
|
||||
.filter((entry) => selectedIds.has(entry.id))
|
||||
.map((entry) => entry.email)
|
||||
|
||||
const response = await fetch('/api/admin/waitlist/bulk', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
emails: selectedEmails,
|
||||
action: 'reject',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Failed to reject selected users')
|
||||
}
|
||||
|
||||
// Refresh data
|
||||
fetchEntries()
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to reject selected users')
|
||||
logger.error('Error rejecting users:', error)
|
||||
} finally {
|
||||
setBulkActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bulk resend approval
|
||||
const handleBulkResend = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
|
||||
setBulkActionLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const selectedEmails = filteredEntries
|
||||
.filter((entry) => selectedIds.has(entry.id) && entry.status === 'approved')
|
||||
.map((entry) => entry.email)
|
||||
|
||||
if (selectedEmails.length === 0) {
|
||||
setError('No approved users selected')
|
||||
setBulkActionLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/waitlist/bulk', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
emails: selectedEmails,
|
||||
action: 'resend',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Failed to resend approval emails')
|
||||
}
|
||||
|
||||
// Refresh data
|
||||
fetchEntries()
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to resend approval emails')
|
||||
logger.error('Error resending approval emails:', error)
|
||||
} finally {
|
||||
setBulkActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const handleNextPage = () => setPage(page + 1)
|
||||
const handlePrevPage = () => setPage(Math.max(page - 1, 1))
|
||||
const handleRefresh = () => fetchEntries()
|
||||
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) => {
|
||||
@@ -486,7 +476,7 @@ export function WaitlistTable() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and bulk actions bar */}
|
||||
{/* Search and refresh bar */}
|
||||
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-4 md:items-center px-6">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -510,86 +500,47 @@ export function WaitlistTable() {
|
||||
>
|
||||
<RotateCcwIcon className={`h-4 w-4 ${loading ? 'animate-spin text-blue-500' : ''}`} />
|
||||
</Button>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap ml-2">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={bulkActionLoading || status === 'approved' || loading}
|
||||
size="sm"
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
>
|
||||
{bulkActionLoading ? (
|
||||
<RotateCcwIcon className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Approve All
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Approve all selected users and send them access emails
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{status === 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleBulkResend}
|
||||
disabled={bulkActionLoading || loading}
|
||||
size="sm"
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||
>
|
||||
{bulkActionLoading ? (
|
||||
<RotateCcwIcon className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<MailIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Resend All
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Resend approval emails to all selected users</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleBulkReject}
|
||||
disabled={bulkActionLoading || status === 'rejected' || loading}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{bulkActionLoading ? (
|
||||
<RotateCcwIcon className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<XIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Reject All
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reject all selected users</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
{/* Enhanced Alert system */}
|
||||
{alertInfo.type && (
|
||||
<Alert
|
||||
variant={
|
||||
alertInfo.type === 'error'
|
||||
? 'destructive'
|
||||
: alertInfo.type === 'email-error'
|
||||
? 'destructive'
|
||||
: alertInfo.type === 'rate-limit'
|
||||
? 'default'
|
||||
: 'default'
|
||||
}
|
||||
className="mx-6 w-auto"
|
||||
>
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertTitle className="ml-2">
|
||||
{alertInfo.type === 'email-error'
|
||||
? 'Email Delivery Failed'
|
||||
: alertInfo.type === 'rate-limit'
|
||||
? 'Rate Limit Exceeded'
|
||||
: 'Error'}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="ml-2 flex items-center justify-between">
|
||||
<span>{alertInfo.message}</span>
|
||||
<Button
|
||||
onClick={() => setAlertInfo({ type: null, message: '' })}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Original error alert - kept for backward compatibility */}
|
||||
{error && !alertInfo.type && (
|
||||
<Alert variant="destructive" className="mx-6 w-auto">
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertDescription className="ml-2">
|
||||
@@ -629,17 +580,6 @@ export function WaitlistTable() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<div className="flex items-center">
|
||||
<button onClick={handleToggleSelectAll} className="focus:outline-none">
|
||||
{selectedIds.size === filteredEntries.length ? (
|
||||
<CheckSquareIcon className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<SquareIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[180px]">Email</TableHead>
|
||||
<TableHead className="min-w-[150px]">Joined</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
@@ -648,19 +588,16 @@ export function WaitlistTable() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEntries.map((entry) => (
|
||||
<TableRow key={entry.id} className="hover:bg-muted/30">
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => toggleSelectEntry(entry.id)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
{selectedIds.has(entry.id) ? (
|
||||
<CheckSquareIcon className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<SquareIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableRow
|
||||
key={entry.id}
|
||||
className={`hover:bg-muted/30 ${
|
||||
alertInfo.entryId === entry.id && alertInfo.type
|
||||
? alertInfo.type === 'error' || alertInfo.type === 'email-error'
|
||||
? 'bg-red-50'
|
||||
: 'bg-amber-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<TableCell className="font-medium">{entry.email}</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
@@ -818,23 +755,54 @@ export function WaitlistTable() {
|
||||
|
||||
{/* Pagination */}
|
||||
{!searchTerm && (
|
||||
<div className="flex items-center justify-between mx-6 my-4 pb-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrevPage} disabled={page === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-2 mx-6 my-4 pb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFirstPage}
|
||||
disabled={page === 1 || loading}
|
||||
title="First Page"
|
||||
>
|
||||
<ChevronsLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1 || loading}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
<span className="ml-1">Prev</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-muted-foreground mx-2">
|
||||
Page {page} of {Math.ceil(totalEntries / 50) || 1}
|
||||
•
|
||||
{totalEntries} total entries
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= Math.ceil(totalEntries / 50)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= Math.ceil(totalEntries / 50) || loading}
|
||||
>
|
||||
<span className="mr-1">Next</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLastPage}
|
||||
disabled={page >= Math.ceil(totalEntries / 50) || loading}
|
||||
title="Last Page"
|
||||
>
|
||||
<ChevronsRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
import { approveWaitlistUser, rejectWaitlistUser, resendApprovalEmail } from '@/lib/waitlist/service'
|
||||
|
||||
const logger = new Logger('WaitlistBulkAPI')
|
||||
|
||||
// Schema for POST request body
|
||||
const bulkActionSchema = z.object({
|
||||
emails: z.array(z.string().email()),
|
||||
action: z.enum(['approve', 'reject', 'resend']),
|
||||
})
|
||||
|
||||
// Admin password from environment variables
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''
|
||||
|
||||
// Check if the request has valid admin password
|
||||
function isAuthorized(request: NextRequest) {
|
||||
// Get authorization header (Bearer token)
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract token
|
||||
const token = authHeader.split(' ')[1]
|
||||
|
||||
// Compare with expected token
|
||||
return token === ADMIN_PASSWORD
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authorization
|
||||
if (!isAuthorized(request)) {
|
||||
return NextResponse.json({ success: false, message: 'Unauthorized access' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json()
|
||||
|
||||
// Validate request
|
||||
const validatedData = bulkActionSchema.safeParse(body)
|
||||
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid request',
|
||||
errors: validatedData.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { emails, action } = validatedData.data
|
||||
|
||||
if (emails.length === 0) {
|
||||
return NextResponse.json({ success: false, message: 'No emails provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Process each email
|
||||
let results
|
||||
try {
|
||||
results = await Promise.allSettled(
|
||||
emails.map((email) => {
|
||||
if (action === 'approve') {
|
||||
return approveWaitlistUser(email)
|
||||
} else if (action === 'reject') {
|
||||
return rejectWaitlistUser(email)
|
||||
} else if (action === 'resend') {
|
||||
return resendApprovalEmail(email)
|
||||
}
|
||||
throw new Error('Invalid action')
|
||||
})
|
||||
)
|
||||
|
||||
// Check if there's a JWT_SECRET error
|
||||
const jwtError = results.find(
|
||||
(r) =>
|
||||
r.status === 'rejected' &&
|
||||
r.reason instanceof Error &&
|
||||
r.reason.message.includes('JWT_SECRET')
|
||||
)
|
||||
|
||||
if (jwtError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message:
|
||||
'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Count successful and failed operations
|
||||
const successful = results.filter(
|
||||
(r) => r.status === 'fulfilled' && (r.value as any).success
|
||||
).length
|
||||
const failed = emails.length - successful
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Processed ${emails.length} entries: ${successful} successful, ${failed} failed`,
|
||||
details: {
|
||||
successful,
|
||||
failed,
|
||||
total: emails.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error in bulk processing:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'An error occurred while processing your request',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Admin waitlist bulk API error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'An error occurred while processing your request',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,37 @@ function isAuthorized(request: NextRequest) {
|
||||
return token === ADMIN_PASSWORD
|
||||
}
|
||||
|
||||
// Catch and handle Resend API errors
|
||||
function detectResendRateLimitError(error: any): boolean {
|
||||
if (!error) return false
|
||||
|
||||
// Check for structured error from Resend
|
||||
if (error.statusCode === 429 ||
|
||||
(error.name && error.name === 'rate_limit_exceeded') ||
|
||||
(error.message && error.message.toLowerCase().includes('rate'))) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check string error message
|
||||
if (typeof error === 'string' &&
|
||||
(error.toLowerCase().includes('rate') ||
|
||||
error.toLowerCase().includes('too many') ||
|
||||
error.toLowerCase().includes('limit'))) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the error is an object, check common properties
|
||||
if (typeof error === 'object') {
|
||||
const errorStr = JSON.stringify(error).toLowerCase()
|
||||
return errorStr.includes('rate') ||
|
||||
errorStr.includes('too many') ||
|
||||
errorStr.includes('limit') ||
|
||||
errorStr.includes('429')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authorization
|
||||
@@ -134,14 +165,67 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const { email, action } = validatedData.data
|
||||
|
||||
let result
|
||||
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)
|
||||
|
||||
// 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 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -153,6 +237,35 @@ export async function POST(request: NextRequest) {
|
||||
{ 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,
|
||||
@@ -177,8 +290,60 @@ export async function POST(request: NextRequest) {
|
||||
} 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)
|
||||
|
||||
// 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: `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(
|
||||
@@ -190,6 +355,35 @@ export async function POST(request: NextRequest) {
|
||||
{ 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,
|
||||
|
||||
@@ -170,7 +170,7 @@ export async function getWaitlistEntries(
|
||||
// Approve a user from the waitlist and send approval email
|
||||
export async function approveWaitlistUser(
|
||||
email: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string; emailError?: any; rateLimited?: boolean }> {
|
||||
try {
|
||||
const { user, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
@@ -188,15 +188,6 @@ export async function approveWaitlistUser(
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to approved
|
||||
await db
|
||||
.update(waitlist)
|
||||
.set({
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(waitlist.email, normalizedEmail))
|
||||
|
||||
// Create a special signup token
|
||||
const token = await createToken({
|
||||
email: normalizedEmail,
|
||||
@@ -207,30 +198,79 @@ export async function approveWaitlistUser(
|
||||
// Generate signup link with token
|
||||
const signupLink = `${process.env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
|
||||
|
||||
// Send approval email
|
||||
// IMPORTANT: Send approval email BEFORE updating the status
|
||||
// This ensures we don't mark users as approved if email fails
|
||||
try {
|
||||
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
|
||||
const subject = getEmailSubject('waitlist-approval')
|
||||
|
||||
await sendEmail({
|
||||
const emailResult = await sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
})
|
||||
|
||||
// If email sending failed, don't update the user status
|
||||
if (!emailResult.success) {
|
||||
console.error('Error sending approval email:', emailResult.message)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (emailResult.message?.toLowerCase().includes('rate') ||
|
||||
emailResult.message?.toLowerCase().includes('too many') ||
|
||||
emailResult.message?.toLowerCase().includes('limit')) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: emailResult.message || 'Failed to send approval email',
|
||||
emailError: emailResult
|
||||
}
|
||||
}
|
||||
|
||||
// Email sent successfully, now update status to approved
|
||||
await db
|
||||
.update(waitlist)
|
||||
.set({
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(waitlist.email, normalizedEmail))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'User approved and email sent'
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error('Error sending approval email:', emailError)
|
||||
// Continue even if email fails - user is still approved in db
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'User approved and email sent',
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (emailError instanceof Error &&
|
||||
(emailError.message.toLowerCase().includes('rate') ||
|
||||
emailError.message.toLowerCase().includes('too many') ||
|
||||
emailError.message.toLowerCase().includes('limit'))) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to send approval email',
|
||||
emailError
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving waitlist user:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while approving user',
|
||||
message: 'An error occurred while approving user'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,7 +397,7 @@ export async function markWaitlistUserAsSignedUp(
|
||||
// Resend approval email to an already approved user
|
||||
export async function resendApprovalEmail(
|
||||
email: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string; emailError?: any; rateLimited?: boolean }> {
|
||||
try {
|
||||
const { user, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
@@ -390,23 +430,59 @@ export async function resendApprovalEmail(
|
||||
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
|
||||
const subject = getEmailSubject('waitlist-approval')
|
||||
|
||||
await sendEmail({
|
||||
const emailResult = await sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
})
|
||||
|
||||
// Check for email sending failures
|
||||
if (!emailResult.success) {
|
||||
console.error('Error sending approval email:', emailResult.message)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (emailResult.message?.toLowerCase().includes('rate') ||
|
||||
emailResult.message?.toLowerCase().includes('too many') ||
|
||||
emailResult.message?.toLowerCase().includes('limit')) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: emailResult.message || 'Failed to send approval email',
|
||||
emailError: emailResult
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Approval email resent successfully',
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error('Error sending approval email:', emailError)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (emailError instanceof Error &&
|
||||
(emailError.message.toLowerCase().includes('rate') ||
|
||||
emailError.message.toLowerCase().includes('too many') ||
|
||||
emailError.message.toLowerCase().includes('limit'))) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to send approval email',
|
||||
emailError
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Approval email resent successfully',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resending approval email:', error)
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user