feat(waitlist): modified waitlist flow, protected signup in production to ensure only user's who've been let off the waitlist can signup (#200)
* added email templates for initial signup and confirmation email * added waitlist flow. when someone signs up, we send them an email and then when we let them off the waitlist, we send them a link to login and change their status in the table * added waitlist store similar to logs store, added admin panel for letting ppl off the waitlist * wrap signup form in a suspense boundary to resolve build error * added already joined notif to waitlist * added rate limiter using redis, added token validation in middleware for protected routes * cleaned up email components, consolidated footer implementation
@@ -1,10 +1,10 @@
|
||||
<p align="center">
|
||||
<img src="sim/public/sim.png" alt="Sim Studio Logo" width="500"/>
|
||||
<img src="sim/public/static/sim.png" alt="Sim Studio Logo" width="500"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.apache.org/licenses/LICENSE-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache-2.0"></a>
|
||||
<a href="https://discord.gg/pQKwMTvNrg"><img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT"><img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simstudioai"><img src="https://img.shields.io/twitter/follow/simstudio?style=social" alt="Twitter"></a>
|
||||
<a href="https://github.com/simstudioai/sim/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome"></a>
|
||||
<a href="https://github.com/simstudioai/sim/issues"><img src="https://img.shields.io/badge/support-contact%20author-purple.svg" alt="support"></a>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ const PASSWORD_VALIDATIONS = {
|
||||
},
|
||||
}
|
||||
|
||||
export default function SignupPage({
|
||||
function SignupFormContent({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
isProduction,
|
||||
@@ -47,6 +47,7 @@ export default function SignupPage({
|
||||
isProduction: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [, setMounted] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
@@ -54,10 +55,15 @@ export default function SignupPage({
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
const emailParam = searchParams.get('email')
|
||||
if (emailParam) {
|
||||
setEmail(emailParam)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Validate password and return array of error messages
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
@@ -100,7 +106,7 @@ export default function SignupPage({
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const email = formData.get('email') as string
|
||||
const emailValue = formData.get('email') as string
|
||||
const passwordValue = formData.get('password') as string
|
||||
const name = formData.get('name') as string
|
||||
|
||||
@@ -121,7 +127,7 @@ export default function SignupPage({
|
||||
|
||||
const response = await client.signUp.email(
|
||||
{
|
||||
email,
|
||||
email: emailValue,
|
||||
password: passwordValue,
|
||||
name,
|
||||
},
|
||||
@@ -169,7 +175,7 @@ export default function SignupPage({
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
sessionStorage.setItem('verificationEmail', emailValue)
|
||||
}
|
||||
|
||||
router.push(`/verify?fromSignup=true`)
|
||||
@@ -221,6 +227,8 @@ export default function SignupPage({
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -275,3 +283,29 @@ export default function SignupPage({
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SignupPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
isProduction,
|
||||
}: {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
isProduction: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignupFormContent
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function NavClient({ children }: { children: React.ReactNode }) {
|
||||
<XIcon />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/pQKwMTvNrg"
|
||||
href="https://discord.gg/Hr4UWYEcTT"
|
||||
className="text-white/80 hover:text-white/100 p-2 rounded-md group hover:scale-[1.04] transition-colors transition-transform duration-200"
|
||||
aria-label="Discord"
|
||||
target="_blank"
|
||||
|
||||
@@ -10,11 +10,17 @@ const emailSchema = z.string().email('Please enter a valid email')
|
||||
export default function WaitlistForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'exists' | 'ratelimited'>(
|
||||
'idle'
|
||||
)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [retryAfter, setRetryAfter] = useState<number | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus('idle')
|
||||
setErrorMessage('')
|
||||
setRetryAfter(null)
|
||||
|
||||
try {
|
||||
// Validate email
|
||||
@@ -32,7 +38,20 @@ export default function WaitlistForm() {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus('error')
|
||||
// Check for rate limiting (429 status)
|
||||
if (response.status === 429) {
|
||||
setStatus('ratelimited')
|
||||
setErrorMessage(data.message || 'Too many attempts. Please try again later.')
|
||||
setRetryAfter(data.retryAfter || 60)
|
||||
}
|
||||
// Check if the error is because the email already exists
|
||||
else if (response.status === 400 && data.message?.includes('already exists')) {
|
||||
setStatus('exists')
|
||||
setErrorMessage('Already on the waitlist')
|
||||
} else {
|
||||
setStatus('error')
|
||||
setErrorMessage(data.message || 'Failed to join waitlist')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,6 +59,7 @@ export default function WaitlistForm() {
|
||||
setEmail('')
|
||||
} catch (error) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Please try again')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -49,9 +69,26 @@ export default function WaitlistForm() {
|
||||
if (isSubmitting) return 'Joining...'
|
||||
if (status === 'success') return 'Joined!'
|
||||
if (status === 'error') return 'Try again'
|
||||
if (status === 'exists') return 'Already joined'
|
||||
if (status === 'ratelimited') return `Try again later`
|
||||
return 'Join waitlist'
|
||||
}
|
||||
|
||||
const getButtonStyle = () => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'bg-green-500 hover:bg-green-600'
|
||||
case 'error':
|
||||
return 'bg-red-500 hover:bg-red-600'
|
||||
case 'exists':
|
||||
return 'bg-amber-500 hover:bg-amber-600'
|
||||
case 'ratelimited':
|
||||
return 'bg-gray-500 hover:bg-gray-600'
|
||||
default:
|
||||
return 'bg-white text-black hover:bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -64,18 +101,12 @@ export default function WaitlistForm() {
|
||||
className="flex-1 text-sm md:text-md lg:text-[16px] bg-[#020817] border-white/20 focus:border-white/30 focus:ring-white/30 rounded-md h-[49px]"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || status === 'ratelimited'}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className={`rounded-md px-8 h-[48px] text-sm md:text-md ${
|
||||
status === 'success'
|
||||
? 'bg-green-500 hover:bg-green-600'
|
||||
: status === 'error'
|
||||
? 'bg-red-500 hover:bg-red-600'
|
||||
: 'bg-white text-black hover:bg-gray-100'
|
||||
}`}
|
||||
disabled={isSubmitting}
|
||||
className={`rounded-md px-8 h-[48px] text-sm md:text-md ${getButtonStyle()}`}
|
||||
disabled={isSubmitting || status === 'ratelimited'}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
|
||||
11
sim/app/admin/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import PasswordAuth from './password-auth'
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<PasswordAuth>
|
||||
<div>
|
||||
<h1>Admin Page</h1>
|
||||
</div>
|
||||
</PasswordAuth>
|
||||
)
|
||||
}
|
||||
99
sim/app/admin/password-auth.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { LockIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
// The admin password for client components should use NEXT_PUBLIC prefix for accessibility
|
||||
// In production, setup appropriate env vars and secure access patterns
|
||||
const ADMIN_PASSWORD = process.env.NEXT_PUBLIC_ADMIN_PASSWORD || ''
|
||||
|
||||
export default function PasswordAuth({ children }: { children: React.ReactNode }) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [isAuthorized, setIsAuthorized] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Check if already authorized in session storage - client-side only
|
||||
useEffect(() => {
|
||||
try {
|
||||
const auth = sessionStorage.getItem('admin-auth')
|
||||
if (auth === 'true') {
|
||||
setIsAuthorized(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error accessing sessionStorage:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (password === ADMIN_PASSWORD) {
|
||||
setIsAuthorized(true)
|
||||
try {
|
||||
sessionStorage.setItem('admin-auth', 'true')
|
||||
sessionStorage.setItem('admin-auth-token', ADMIN_PASSWORD)
|
||||
} catch (error) {
|
||||
console.error('Error setting sessionStorage:', error)
|
||||
}
|
||||
setError(null)
|
||||
} else {
|
||||
setError('Incorrect password')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-muted-foreground">Checking authentication...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthorized) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full shadow-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<LockIcon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Admin Access</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Enter your admin password to continue
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter admin password"
|
||||
autoComplete="off"
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Access Admin
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
sim/app/admin/waitlist/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Metadata } from 'next'
|
||||
import PasswordAuth from '../password-auth'
|
||||
import { WaitlistTable } from './waitlist-table'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Waitlist Management | Sim Studio',
|
||||
description: 'Manage the waitlist for Sim Studio',
|
||||
}
|
||||
|
||||
export default function WaitlistPage() {
|
||||
return (
|
||||
<PasswordAuth>
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 md:px-8 py-10">
|
||||
<div className="mb-8 px-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Waitlist Management</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Review and manage users who have signed up for the waitlist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-none shadow-md bg-white dark:bg-gray-950 rounded-md">
|
||||
<WaitlistTable />
|
||||
</div>
|
||||
</div>
|
||||
</PasswordAuth>
|
||||
)
|
||||
}
|
||||
187
sim/app/admin/waitlist/stores/store.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
// Define types inline since types.ts was deleted
|
||||
export type WaitlistStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
export interface WaitlistEntry {
|
||||
id: string
|
||||
email: string
|
||||
status: WaitlistStatus
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
interface WaitlistState {
|
||||
// Core data
|
||||
entries: WaitlistEntry[]
|
||||
filteredEntries: WaitlistEntry[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
// Filters
|
||||
status: string
|
||||
searchTerm: string
|
||||
|
||||
// Pagination
|
||||
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) => ({
|
||||
// Core data
|
||||
entries: [],
|
||||
filteredEntries: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// Filters
|
||||
status: 'all',
|
||||
searchTerm: '',
|
||||
|
||||
// Pagination
|
||||
page: 1,
|
||||
totalEntries: 0,
|
||||
|
||||
// Selection
|
||||
selectedIds: new Set<string>(),
|
||||
|
||||
// Loading states
|
||||
actionLoading: null,
|
||||
bulkActionLoading: false,
|
||||
|
||||
// Filter actions
|
||||
setStatus: (status) => {
|
||||
console.log('Store: Setting status to', status)
|
||||
set({
|
||||
status,
|
||||
page: 1,
|
||||
searchTerm: '',
|
||||
selectedIds: new Set(),
|
||||
loading: true,
|
||||
})
|
||||
get().fetchEntries()
|
||||
},
|
||||
|
||||
setSearchTerm: (searchTerm) => {
|
||||
set({ searchTerm, page: 1, loading: true })
|
||||
get().fetchEntries()
|
||||
},
|
||||
|
||||
setPage: (page) => {
|
||||
set({ page, loading: true })
|
||||
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({
|
||||
entries,
|
||||
filteredEntries: entries,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setActionLoading: (id) => set({ actionLoading: id }),
|
||||
setBulkActionLoading: (loading) => set({ bulkActionLoading: loading }),
|
||||
|
||||
// Fetch data
|
||||
fetchEntries: async () => {
|
||||
const { status, page, searchTerm } = get()
|
||||
|
||||
try {
|
||||
set({ loading: true, error: null })
|
||||
|
||||
// Prevent caching with timestamp
|
||||
const timestamp = Date.now()
|
||||
const searchParam = searchTerm ? `&search=${encodeURIComponent(searchTerm)}` : ''
|
||||
const url = `/api/admin/waitlist?page=${page}&limit=50&status=${status}&t=${timestamp}${searchParam}`
|
||||
|
||||
// Get the auth token
|
||||
const token = sessionStorage.getItem('admin-auth-token') || ''
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Cache-Control': 'no-cache, must-revalidate',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Failed to load waitlist entries')
|
||||
}
|
||||
|
||||
// Process entries
|
||||
const entries = data.data.entries.map((entry: any) => ({
|
||||
...entry,
|
||||
createdAt: new Date(entry.createdAt),
|
||||
updatedAt: new Date(entry.updatedAt),
|
||||
}))
|
||||
|
||||
// Update state
|
||||
set({
|
||||
entries,
|
||||
filteredEntries: entries,
|
||||
totalEntries: data.data.total,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching waitlist entries:', error)
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
679
sim/app/admin/waitlist/waitlist-table.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckIcon,
|
||||
CheckSquareIcon,
|
||||
InfoIcon,
|
||||
MailIcon,
|
||||
RotateCcwIcon,
|
||||
SearchIcon,
|
||||
SquareIcon,
|
||||
UserCheckIcon,
|
||||
UserIcon,
|
||||
UserXIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import { Alert, AlertDescription } 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
|
||||
}
|
||||
|
||||
// Filter button component
|
||||
const FilterButton = ({ active, onClick, icon, label, className }: FilterButtonProps) => (
|
||||
<Button
|
||||
variant={active ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 ${className || ''}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
export function WaitlistTable() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// Get all values from the store
|
||||
const {
|
||||
entries,
|
||||
filteredEntries,
|
||||
status,
|
||||
searchTerm,
|
||||
page,
|
||||
totalEntries,
|
||||
loading,
|
||||
error,
|
||||
selectedIds,
|
||||
actionLoading,
|
||||
bulkActionLoading,
|
||||
setStatus,
|
||||
setSearchTerm,
|
||||
setPage,
|
||||
toggleSelectEntry,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
setActionLoading,
|
||||
setBulkActionLoading,
|
||||
setError,
|
||||
fetchEntries,
|
||||
} = useWaitlistStore()
|
||||
|
||||
// Local state for search input with debounce
|
||||
const [searchInputValue, setSearchInputValue] = useState(searchTerm)
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 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<HTMLInputElement>) => {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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 || !data.success) {
|
||||
throw new Error(data.message || 'Failed to approve user')
|
||||
}
|
||||
|
||||
// Refresh the data
|
||||
fetchEntries()
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to approve user')
|
||||
logger.error('Error approving user:', error)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle individual rejection
|
||||
const handleReject = async (email: string, id: string) => {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
|
||||
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) {
|
||||
throw new Error(data.message || 'Failed to reject user')
|
||||
}
|
||||
|
||||
// Refresh the data
|
||||
fetchEntries()
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to reject user')
|
||||
logger.error('Error rejecting user:', 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const handleNextPage = () => setPage(page + 1)
|
||||
const handlePrevPage = () => setPage(Math.max(page - 1, 1))
|
||||
const handleRefresh = () => fetchEntries()
|
||||
|
||||
// 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 (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 w-full">
|
||||
{/* Filter bar - similar to logs.tsx */}
|
||||
<div className="border-b px-6">
|
||||
<div className="flex flex-wrap items-center gap-2 py-3">
|
||||
<FilterButton
|
||||
active={status === 'all'}
|
||||
onClick={() => handleStatusChange('all')}
|
||||
icon={<UserIcon className="h-4 w-4" />}
|
||||
label="All"
|
||||
className={
|
||||
status === 'all'
|
||||
? 'bg-blue-100 text-blue-900 hover:bg-blue-200 hover:text-blue-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={status === 'pending'}
|
||||
onClick={() => handleStatusChange('pending')}
|
||||
icon={<UserIcon className="h-4 w-4" />}
|
||||
label="Pending"
|
||||
className={
|
||||
status === 'pending'
|
||||
? 'bg-amber-100 text-amber-900 hover:bg-amber-200 hover:text-amber-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={status === 'approved'}
|
||||
onClick={() => handleStatusChange('approved')}
|
||||
icon={<UserCheckIcon className="h-4 w-4" />}
|
||||
label="Approved"
|
||||
className={
|
||||
status === 'approved'
|
||||
? 'bg-green-100 text-green-900 hover:bg-green-200 hover:text-green-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={status === 'rejected'}
|
||||
onClick={() => handleStatusChange('rejected')}
|
||||
icon={<UserXIcon className="h-4 w-4" />}
|
||||
label="Rejected"
|
||||
className={
|
||||
status === 'rejected'
|
||||
? 'bg-red-100 text-red-900 hover:bg-red-200 hover:text-red-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and bulk actions 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" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email..."
|
||||
value={searchInputValue}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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 && (
|
||||
<Alert variant="destructive" className="mx-6 w-auto">
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertDescription className="ml-2">
|
||||
{error}
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm" className="ml-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading ? (
|
||||
<div className="space-y-4 px-6">
|
||||
<div className="space-y-2 w-full">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className="rounded-md border mx-6 p-8 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<InfoIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">No entries found</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{searchTerm
|
||||
? 'No matching entries found with the current search term'
|
||||
: `No ${status === 'all' ? '' : status} entries found in the waitlist.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="rounded-md border mx-6 overflow-hidden">
|
||||
<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>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</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>
|
||||
<TableCell className="font-medium">{entry.email}</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{formatDate(entry.createdAt)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getDetailedTimeTooltip(entry.createdAt)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
entry.status === 'approved'
|
||||
? 'default'
|
||||
: entry.status === 'rejected'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
className={
|
||||
entry.status === 'approved'
|
||||
? 'bg-green-100 text-green-800 border border-green-200 hover:bg-green-200'
|
||||
: entry.status === 'rejected'
|
||||
? 'bg-red-100 text-red-800 border border-red-200 hover:bg-red-200'
|
||||
: 'bg-amber-100 text-amber-800 border border-amber-200 hover:bg-amber-200'
|
||||
}
|
||||
>
|
||||
{entry.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{entry.status !== 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleApprove(entry.email, entry.id)}
|
||||
disabled={actionLoading === entry.id}
|
||||
className="hover:border-green-500 hover:text-green-600"
|
||||
>
|
||||
{actionLoading === entry.id ? (
|
||||
<RotateCcwIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approve user and send access email</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{entry.status !== 'rejected' && entry.status !== 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleReject(entry.email, entry.id)}
|
||||
disabled={actionLoading === entry.id}
|
||||
className="hover:border-red-500 hover:text-red-600"
|
||||
>
|
||||
{actionLoading === entry.id ? (
|
||||
<RotateCcwIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<XIcon className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reject user</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://mail.google.com/mail/?view=cm&fs=1&to=${entry.email}`
|
||||
)
|
||||
}
|
||||
className="hover:border-blue-500 hover:text-blue-600"
|
||||
>
|
||||
<MailIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Email user in Gmail</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
sim/app/api/admin/waitlist/bulk/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
import { approveWaitlistUser, rejectWaitlistUser } 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']),
|
||||
})
|
||||
|
||||
// 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) =>
|
||||
action === 'approve' ? approveWaitlistUser(email) : rejectWaitlistUser(email)
|
||||
)
|
||||
)
|
||||
|
||||
// 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
199
sim/app/api/admin/waitlist/route.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
import { approveWaitlistUser, getWaitlistEntries, rejectWaitlistUser } from '@/lib/waitlist/service'
|
||||
|
||||
const logger = new Logger('WaitlistAPI')
|
||||
|
||||
// Schema for GET request query parameters
|
||||
const getQuerySchema = z.object({
|
||||
page: z.coerce.number().optional().default(1),
|
||||
limit: z.coerce.number().optional().default(20),
|
||||
status: z.enum(['all', 'pending', 'approved', 'rejected']).optional(),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
|
||||
// Schema for POST request body
|
||||
const actionSchema = z.object({
|
||||
email: z.string().email(),
|
||||
action: z.enum(['approve', 'reject']),
|
||||
})
|
||||
|
||||
// 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 GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authorization
|
||||
if (!isAuthorized(request)) {
|
||||
return NextResponse.json({ success: false, message: 'Unauthorized access' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const { searchParams } = request.nextUrl
|
||||
const page = searchParams.get('page') ? Number(searchParams.get('page')) : 1
|
||||
const limit = searchParams.get('limit') ? Number(searchParams.get('limit')) : 20
|
||||
const status = searchParams.get('status') || 'all'
|
||||
const search = searchParams.get('search') || undefined
|
||||
|
||||
logger.info(
|
||||
`API route: Received request with status: "${status}", search: "${search || 'none'}", page: ${page}, limit: ${limit}`
|
||||
)
|
||||
|
||||
// Validate params
|
||||
const validatedParams = getQuerySchema.safeParse({ page, limit, status, search })
|
||||
|
||||
if (!validatedParams.success) {
|
||||
logger.error('Invalid parameters:', validatedParams.error.format())
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid parameters',
|
||||
errors: validatedParams.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get waitlist entries with search parameter
|
||||
const entries = await getWaitlistEntries(
|
||||
validatedParams.data.page,
|
||||
validatedParams.data.limit,
|
||||
validatedParams.data.status,
|
||||
validatedParams.data.search
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`API route: Returning ${entries.entries.length} entries for status: "${status}", total: ${entries.total}`
|
||||
)
|
||||
|
||||
// Return response with cache control header to prevent caching
|
||||
return new NextResponse(JSON.stringify({ success: true, data: entries }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin waitlist API error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'An error occurred while processing your request',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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
|
||||
|
||||
let result
|
||||
|
||||
// Perform the requested action
|
||||
if (action === 'approve') {
|
||||
try {
|
||||
result = await approveWaitlistUser(email)
|
||||
} 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 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to approve user',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else if (action === 'reject') {
|
||||
try {
|
||||
result = await rejectWaitlistUser(email)
|
||||
} catch (error) {
|
||||
logger.error('Error rejecting waitlist user:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to reject user',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin waitlist API error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'An error occurred while processing your request',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
50
sim/app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import { session, user } from '@/db/schema'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get session by token
|
||||
const sessionRecord = await db
|
||||
.select()
|
||||
.from(session)
|
||||
.where(eq(session.id, token))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!sessionRecord) {
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get user from session
|
||||
const userRecord = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, sessionRecord.userId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!userRecord) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Return minimal user info (only what's needed)
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: userRecord.id,
|
||||
email: userRecord.email,
|
||||
name: userRecord.name,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Session API error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,76 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { waitlist } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WaitlistAPI')
|
||||
import { isRateLimited } from '@/lib/waitlist/rate-limiter'
|
||||
import { addToWaitlist } from '@/lib/waitlist/service'
|
||||
|
||||
const waitlistSchema = z.object({
|
||||
email: z.string().email(),
|
||||
email: z.string().email('Please enter a valid email'),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
export async function POST(request: NextRequest) {
|
||||
const rateLimitCheck = await isRateLimited(request, 'waitlist')
|
||||
if (rateLimitCheck.limited) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: rateLimitCheck.message || 'Too many requests. Please try again later.',
|
||||
retryAfter: rateLimitCheck.remainingTime,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimitCheck.remainingTime || 60),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await request.json()
|
||||
const { email } = waitlistSchema.parse(body)
|
||||
|
||||
// Check if email already exists
|
||||
const existingEntry = await db
|
||||
.select()
|
||||
.from(waitlist)
|
||||
.where(eq(waitlist.email, email))
|
||||
.execute()
|
||||
// Validate the request
|
||||
const validatedData = waitlistSchema.safeParse(body)
|
||||
|
||||
if (existingEntry.length > 0) {
|
||||
return NextResponse.json({ message: 'Email already registered' }, { status: 400 })
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid email address',
|
||||
errors: validatedData.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Add to waitlist
|
||||
await db.insert(waitlist).values({
|
||||
id: nanoid(),
|
||||
email,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
const { email } = validatedData.data
|
||||
|
||||
return NextResponse.json({ message: 'Successfully joined waitlist' }, { status: 200 })
|
||||
// Add the email to the waitlist and send confirmation email
|
||||
const result = await addToWaitlist(email)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: result.message,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully added to waitlist',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Waitlist error`, error)
|
||||
return NextResponse.json({ message: 'Failed to join waitlist' }, { status: 500 })
|
||||
console.error('Waitlist API error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'An error occurred while processing your request',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
118
sim/components/emails/footer.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react'
|
||||
import { Container, Img, Link, Section, Text } from '@react-email/components'
|
||||
|
||||
interface EmailFooterProps {
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
export const EmailFooter = ({
|
||||
baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai',
|
||||
}: EmailFooterProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Section style={{ maxWidth: '580px', margin: '0 auto', padding: '20px 0' }}>
|
||||
<table style={{ width: '100%' }}>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table cellPadding={0} cellSpacing={0} style={{ border: 0 }}>
|
||||
<tr>
|
||||
<td align="center" style={{ padding: '0 8px' }}>
|
||||
<Link href="https://x.com/simstudioai">
|
||||
<Img src={`${baseUrl}/static/x-icon.png`} width="24" height="24" alt="X" />
|
||||
</Link>
|
||||
</td>
|
||||
<td align="center" style={{ padding: '0 8px' }}>
|
||||
<Link href="https://discord.gg/Hr4UWYEcTT">
|
||||
<Img
|
||||
src={`${baseUrl}/static/discord-icon.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Discord"
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
<td align="center" style={{ padding: '0 8px' }}>
|
||||
<Link href="https://github.com/simstudioai/sim">
|
||||
<Img
|
||||
src={`${baseUrl}/static/github-icon.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="GitHub"
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style={{ paddingTop: '12px' }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#706a7b',
|
||||
margin: '8px 0 0 0',
|
||||
}}
|
||||
>
|
||||
© {new Date().getFullYear()} Sim Studio, All Rights Reserved
|
||||
<br />
|
||||
If you have any questions, please contact us at{' '}
|
||||
<a
|
||||
href="mailto:help@simstudio.ai"
|
||||
style={{
|
||||
color: '#706a7b !important',
|
||||
textDecoration: 'underline',
|
||||
fontWeight: 'normal',
|
||||
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
help@simstudio.ai
|
||||
</a>
|
||||
</Text>
|
||||
<table cellPadding={0} cellSpacing={0} style={{ width: '100%', marginTop: '4px' }}>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<p
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#706a7b',
|
||||
margin: '8px 0 0 0',
|
||||
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={`${baseUrl}/privacy`}
|
||||
style={{
|
||||
color: '#706a7b !important',
|
||||
textDecoration: 'underline',
|
||||
fontWeight: 'normal',
|
||||
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{' '}
|
||||
•{' '}
|
||||
<a
|
||||
href={`${baseUrl}/terms`}
|
||||
style={{
|
||||
color: '#706a7b !important',
|
||||
textDecoration: 'underline',
|
||||
fontWeight: 'normal',
|
||||
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</Section>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailFooter
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
interface OTPVerificationEmailProps {
|
||||
otp: string
|
||||
@@ -46,22 +46,19 @@ export const OTPVerificationEmail = ({
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>{getSubjectByType(type)}</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section
|
||||
style={{
|
||||
...baseStyles.header,
|
||||
textAlign: 'center',
|
||||
padding: '30px',
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={`${baseUrl}/sim.png`}
|
||||
width="114"
|
||||
alt="Sim Studio"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/sim.png`}
|
||||
width="114"
|
||||
alt="Sim Studio"
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
@@ -95,52 +92,7 @@ export const OTPVerificationEmail = ({
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Section style={baseStyles.footer}>
|
||||
<Row>
|
||||
<Column align="right" style={{ width: '50%', paddingRight: '8px' }}>
|
||||
<Link href="https://x.com/simstudioai" style={{ textDecoration: 'none' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/x-icon.png`}
|
||||
width="20"
|
||||
height="20"
|
||||
alt="X"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
filter: 'grayscale(100%)',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column align="left" style={{ width: '50%', paddingLeft: '8px' }}>
|
||||
<Link href="https://discord.gg/crdsGfGk" style={{ textDecoration: 'none' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/discord-icon.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Discord"
|
||||
style={{
|
||||
display: 'block',
|
||||
filter: 'grayscale(100%)',
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.footerText,
|
||||
textAlign: 'center',
|
||||
color: '#706a7b',
|
||||
}}
|
||||
>
|
||||
© {new Date().getFullYear()} Sim Studio, All Rights Reserved
|
||||
<br />
|
||||
If you have any questions, please contact us at support@simstudio.ai
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { renderAsync } from '@react-email/components'
|
||||
import { OTPVerificationEmail } from './otp-verification-email'
|
||||
import { ResetPasswordEmail } from './reset-password-email'
|
||||
import { WaitlistApprovalEmail } from './waitlist-approval-email'
|
||||
import { WaitlistConfirmationEmail } from './waitlist-confirmation-email'
|
||||
|
||||
/**
|
||||
* Renders the OTP verification email to HTML
|
||||
@@ -23,11 +25,34 @@ export async function renderPasswordResetEmail(
|
||||
return await renderAsync(ResetPasswordEmail({ username, resetLink, updatedDate: new Date() }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the waitlist confirmation email to HTML
|
||||
*/
|
||||
export async function renderWaitlistConfirmationEmail(email: string): Promise<string> {
|
||||
return await renderAsync(WaitlistConfirmationEmail({ email }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the waitlist approval email to HTML
|
||||
*/
|
||||
export async function renderWaitlistApprovalEmail(
|
||||
email: string,
|
||||
signupLink: string
|
||||
): Promise<string> {
|
||||
return await renderAsync(WaitlistApprovalEmail({ email, signupLink }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate email subject based on email type
|
||||
*/
|
||||
export function getEmailSubject(
|
||||
type: 'sign-in' | 'email-verification' | 'forget-password' | 'reset-password'
|
||||
type:
|
||||
| 'sign-in'
|
||||
| 'email-verification'
|
||||
| 'forget-password'
|
||||
| 'reset-password'
|
||||
| 'waitlist-confirmation'
|
||||
| 'waitlist-approval'
|
||||
): string {
|
||||
switch (type) {
|
||||
case 'sign-in':
|
||||
@@ -38,6 +63,10 @@ export function getEmailSubject(
|
||||
return 'Reset your Sim Studio password'
|
||||
case 'reset-password':
|
||||
return 'Reset your Sim Studio password'
|
||||
case 'waitlist-confirmation':
|
||||
return 'Welcome to the Sim Studio Waitlist'
|
||||
case 'waitlist-approval':
|
||||
return "You've Been Approved to Join Sim Studio!"
|
||||
default:
|
||||
return 'Sim Studio'
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { format } from 'date-fns'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
interface ResetPasswordEmailProps {
|
||||
username?: string
|
||||
@@ -24,37 +26,30 @@ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
|
||||
export const ResetPasswordEmail = ({
|
||||
username = '',
|
||||
resetLink = 'https://simstudio.ai/reset-password',
|
||||
resetLink = '',
|
||||
updatedDate = new Date(),
|
||||
}: ResetPasswordEmailProps) => {
|
||||
const formattedDate = new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(updatedDate)
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>Reset your Sim Studio password</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section
|
||||
style={{
|
||||
...baseStyles.header,
|
||||
textAlign: 'center',
|
||||
padding: '30px',
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={`${baseUrl}/sim.png`}
|
||||
width="114"
|
||||
alt="Sim Studio"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/sim.png`}
|
||||
width="114"
|
||||
alt="Sim Studio"
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
@@ -62,78 +57,40 @@ export const ResetPasswordEmail = ({
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Hello {username},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We received a request to reset your Sim Studio password. Click the button below to set
|
||||
a new password:
|
||||
You recently requested to reset your password for your Sim Studio account. Use the
|
||||
button below to reset it. This password reset is only valid for the next 24 hours.
|
||||
</Text>
|
||||
<Section style={{ textAlign: 'center' }}>
|
||||
<Link style={baseStyles.button} href={resetLink}>
|
||||
Reset Password
|
||||
</Link>
|
||||
</Section>
|
||||
<Link href={resetLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Reset Your Password</Text>
|
||||
</Link>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
If you did not request a password reset, please ignore this email or contact support
|
||||
if you have concerns.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
For security reasons, this password reset link will expire in 24 hours.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Studio Team
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.footerText,
|
||||
marginTop: '40px',
|
||||
textAlign: 'left',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
This email was sent on {format(updatedDate, 'MMMM do, yyyy')} because a password reset
|
||||
was requested for your account.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Section style={baseStyles.footer}>
|
||||
<Row>
|
||||
<Column align="right" style={{ width: '50%', paddingRight: '8px' }}>
|
||||
<Link href="https://x.com/simstudioai" style={{ textDecoration: 'none' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/x-icon.png`}
|
||||
width="20"
|
||||
height="20"
|
||||
alt="X"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
filter: 'grayscale(100%)',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column align="left" style={{ width: '50%', paddingLeft: '8px' }}>
|
||||
<Link href="https://discord.gg/crdsGfGk" style={{ textDecoration: 'none' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/discord-icon.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Discord"
|
||||
style={{
|
||||
display: 'block',
|
||||
filter: 'grayscale(100%)',
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.footerText,
|
||||
textAlign: 'center',
|
||||
color: '#706a7b',
|
||||
}}
|
||||
>
|
||||
© {new Date().getFullYear()} Sim Studio, All Rights Reserved
|
||||
<br />
|
||||
If you have any questions, please contact us at support@simstudio.ai
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
89
sim/components/emails/waitlist-approval-email.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
interface WaitlistApprovalEmailProps {
|
||||
email?: string
|
||||
signupLink?: string
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
|
||||
export const WaitlistApprovalEmail = ({
|
||||
email = '',
|
||||
signupLink = '',
|
||||
}: WaitlistApprovalEmailProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>You've Been Approved to Join Sim Studio!</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/sim.png`}
|
||||
width="114"
|
||||
alt="Sim Studio"
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Great news!</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've been approved to join Sim Studio! We're excited to have you as part of our
|
||||
community of developers building, testing, and optimizing AI workflows.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your email ({email}) has been approved. Click the button below to create your account
|
||||
and start using Sim Studio today:
|
||||
</Text>
|
||||
<Link href={signupLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Create Your Account</Text>
|
||||
</Link>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
This approval link will expire in 7 days. If you have any questions or need
|
||||
assistance, feel free to reach out to our support team.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Studio Team
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default WaitlistApprovalEmail
|
||||
85
sim/components/emails/waitlist-confirmation-email.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
interface WaitlistConfirmationEmailProps {
|
||||
email?: string
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
const calendlyLink = 'https://calendly.com/emir-simstudio/15min'
|
||||
|
||||
export const WaitlistConfirmationEmail = ({ email = '' }: WaitlistConfirmationEmailProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>Welcome to the Sim Studio Waitlist!</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/sim.png`}
|
||||
width="114"
|
||||
alt="Sim Studio"
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Welcome to the Sim Studio Waitlist!</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Thank you for your interest in Sim Studio. We've added your email ({email}) to our
|
||||
waitlist and will notify you as soon as you're granted access.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Want to get access sooner?</strong> Tell us about your use case! Schedule a
|
||||
15-minute call with our team to discuss how you plan to use Sim Studio.
|
||||
</Text>
|
||||
<Link href={calendlyLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Schedule a Call</Text>
|
||||
</Link>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We're excited to help you build, test, and optimize your agentic workflows.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Studio Team
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default WaitlistConfirmationEmail
|
||||
90
sim/components/ui/table.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||
))
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
@@ -1,59 +1,12 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
|
||||
// Check if we're in local storage mode (CLI usage with npx simstudio)
|
||||
const isLocalStorage = process.env.USE_LOCAL_STORAGE === 'true'
|
||||
// In production, use the Vercel-generated POSTGRES_URL
|
||||
// In development, use the direct DATABASE_URL
|
||||
const connectionString = process.env.POSTGRES_URL || process.env.DATABASE_URL!
|
||||
|
||||
// Create a type for our database client
|
||||
type DrizzleClient = ReturnType<typeof drizzle>
|
||||
|
||||
// Create a mock implementation for localStorage mode
|
||||
const createMockDb = (): DrizzleClient => {
|
||||
const mockHandler = {
|
||||
get: (target: any, prop: string) => {
|
||||
if (typeof prop === 'string') {
|
||||
return (...args: any[]) => {
|
||||
const chainableMock = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (target, chainProp) => {
|
||||
if (chainProp === 'then') {
|
||||
return (resolve: Function) => resolve([])
|
||||
}
|
||||
return chainableMock
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return chainableMock
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
return new Proxy({} as DrizzleClient, mockHandler)
|
||||
}
|
||||
|
||||
// Initialize the database client
|
||||
let db: DrizzleClient
|
||||
|
||||
if (!isLocalStorage) {
|
||||
// In production, use the Vercel-generated POSTGRES_URL
|
||||
// In development, use the direct DATABASE_URL
|
||||
const connectionString = process.env.POSTGRES_URL || process.env.DATABASE_URL!
|
||||
|
||||
// Disable prefetch as it is not supported for "Transaction" pool mode
|
||||
const client = postgres(connectionString, {
|
||||
prepare: false,
|
||||
idle_timeout: 30, // Keep connections alive for 30 seconds when idle
|
||||
connect_timeout: 30, // Timeout after 30 seconds when connecting
|
||||
})
|
||||
db = drizzle(client)
|
||||
} else {
|
||||
// Use mock implementation in localStorage mode
|
||||
db = createMockDb()
|
||||
}
|
||||
|
||||
// Export the database client (never null)
|
||||
export { db }
|
||||
// Disable prefetch as it is not supported for "Transaction" pool mode
|
||||
const client = postgres(connectionString, {
|
||||
prepare: false,
|
||||
})
|
||||
export const db = drizzle(client)
|
||||
|
||||
55
sim/lib/mailer.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Resend } from 'resend'
|
||||
|
||||
interface EmailOptions {
|
||||
to: string
|
||||
subject: string
|
||||
html: string
|
||||
from?: string
|
||||
}
|
||||
|
||||
interface SendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
// Initialize Resend with API key
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
export async function sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
from,
|
||||
}: EmailOptions): Promise<SendEmailResult> {
|
||||
try {
|
||||
const senderEmail = from || 'noreply@simstudio.ai'
|
||||
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `Sim Studio <${senderEmail}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Resend API error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Failed to send email',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully',
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to send email',
|
||||
}
|
||||
}
|
||||
}
|
||||
154
sim/lib/waitlist/rate-limiter.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getRedisClient } from '../redis'
|
||||
|
||||
// Configuration
|
||||
const RATE_LIMIT_WINDOW = 60 // 1 minute window (in seconds)
|
||||
const WAITLIST_MAX_REQUESTS = 5 // 5 requests per minute per IP
|
||||
const WAITLIST_BLOCK_DURATION = 15 * 60 // 15 minutes block (in seconds)
|
||||
|
||||
// Environment detection
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
// Fallback in-memory store for development or if Redis fails
|
||||
const inMemoryStore = new Map<
|
||||
string,
|
||||
{ count: number; timestamp: number; blocked: boolean; blockedUntil?: number }
|
||||
>()
|
||||
|
||||
// Clean up in-memory store periodically (only used in development)
|
||||
if (!isProduction && typeof setInterval !== 'undefined') {
|
||||
setInterval(
|
||||
() => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
for (const [key, data] of inMemoryStore.entries()) {
|
||||
if (data.blocked && data.blockedUntil && data.blockedUntil < now) {
|
||||
inMemoryStore.delete(key)
|
||||
} else if (!data.blocked && now - data.timestamp > RATE_LIMIT_WINDOW) {
|
||||
inMemoryStore.delete(key)
|
||||
}
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// Get client IP from request
|
||||
export function getClientIp(request: NextRequest): string {
|
||||
const xff = request.headers.get('x-forwarded-for')
|
||||
const realIp = request.headers.get('x-real-ip')
|
||||
|
||||
if (xff) {
|
||||
const ips = xff.split(',')
|
||||
return ips[0].trim()
|
||||
}
|
||||
|
||||
return realIp || '0.0.0.0'
|
||||
}
|
||||
|
||||
// Check if a request is rate limited
|
||||
export async function isRateLimited(
|
||||
request: NextRequest,
|
||||
type: 'waitlist' = 'waitlist'
|
||||
): Promise<{
|
||||
limited: boolean
|
||||
message?: string
|
||||
remainingTime?: number
|
||||
}> {
|
||||
const clientIp = getClientIp(request)
|
||||
const key = `ratelimit:${type}:${clientIp}`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
// Get the shared Redis client
|
||||
const redisClient = getRedisClient()
|
||||
|
||||
// Use Redis if available
|
||||
if (redisClient) {
|
||||
try {
|
||||
// Check if IP is blocked
|
||||
const isBlocked = await redisClient.get(`${key}:blocked`)
|
||||
|
||||
if (isBlocked) {
|
||||
const ttl = await redisClient.ttl(`${key}:blocked`)
|
||||
if (ttl > 0) {
|
||||
return {
|
||||
limited: true,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
remainingTime: ttl,
|
||||
}
|
||||
}
|
||||
// Block expired, remove it
|
||||
await redisClient.del(`${key}:blocked`)
|
||||
}
|
||||
|
||||
// Increment counter with expiry
|
||||
const count = await redisClient.incr(key)
|
||||
|
||||
// Set expiry on first request
|
||||
if (count === 1) {
|
||||
await redisClient.expire(key, RATE_LIMIT_WINDOW)
|
||||
}
|
||||
|
||||
// If limit exceeded, block the IP
|
||||
if (count > WAITLIST_MAX_REQUESTS) {
|
||||
await redisClient.set(`${key}:blocked`, '1', 'EX', WAITLIST_BLOCK_DURATION)
|
||||
|
||||
return {
|
||||
limited: true,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
remainingTime: WAITLIST_BLOCK_DURATION,
|
||||
}
|
||||
}
|
||||
|
||||
return { limited: false }
|
||||
} catch (error) {
|
||||
console.error('Redis rate limit error:', error)
|
||||
// Fall back to in-memory if Redis fails
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory fallback implementation
|
||||
let record = inMemoryStore.get(key)
|
||||
|
||||
// Check if IP is blocked
|
||||
if (record?.blocked) {
|
||||
if (record.blockedUntil && record.blockedUntil < now) {
|
||||
record = { count: 1, timestamp: now, blocked: false }
|
||||
inMemoryStore.set(key, record)
|
||||
return { limited: false }
|
||||
}
|
||||
|
||||
const remainingTime = record.blockedUntil ? record.blockedUntil - now : WAITLIST_BLOCK_DURATION
|
||||
return {
|
||||
limited: true,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
remainingTime,
|
||||
}
|
||||
}
|
||||
|
||||
// If no record exists or window expired, create/reset it
|
||||
if (!record || now - record.timestamp > RATE_LIMIT_WINDOW) {
|
||||
record = { count: 1, timestamp: now, blocked: false }
|
||||
inMemoryStore.set(key, record)
|
||||
return { limited: false }
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
record.count++
|
||||
|
||||
// If limit exceeded, block the IP
|
||||
if (record.count > WAITLIST_MAX_REQUESTS) {
|
||||
record.blocked = true
|
||||
record.blockedUntil = now + WAITLIST_BLOCK_DURATION
|
||||
inMemoryStore.set(key, record)
|
||||
|
||||
return {
|
||||
limited: true,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
remainingTime: WAITLIST_BLOCK_DURATION,
|
||||
}
|
||||
}
|
||||
|
||||
inMemoryStore.set(key, record)
|
||||
return { limited: false }
|
||||
}
|
||||
312
sim/lib/waitlist/service.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { and, count, desc, eq, like, or, SQL } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import {
|
||||
getEmailSubject,
|
||||
renderWaitlistApprovalEmail,
|
||||
renderWaitlistConfirmationEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
import { sendEmail } from '@/lib/mailer'
|
||||
import { createToken, verifyToken } from '@/lib/waitlist/token'
|
||||
import { db } from '@/db'
|
||||
import { waitlist } from '@/db/schema'
|
||||
|
||||
// Define types for better type safety
|
||||
export type WaitlistStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
export interface WaitlistEntry {
|
||||
id: string
|
||||
email: string
|
||||
status: WaitlistStatus
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Helper function to find a user by email
|
||||
async function findUserByEmail(email: string) {
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
const users = await db.select().from(waitlist).where(eq(waitlist.email, normalizedEmail)).limit(1)
|
||||
|
||||
return {
|
||||
users,
|
||||
user: users.length > 0 ? users[0] : null,
|
||||
normalizedEmail,
|
||||
}
|
||||
}
|
||||
|
||||
// Add a user to the waitlist
|
||||
export async function addToWaitlist(email: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { users, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
if (users.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Email already exists in waitlist',
|
||||
}
|
||||
}
|
||||
|
||||
// Add to waitlist
|
||||
await db.insert(waitlist).values({
|
||||
id: nanoid(),
|
||||
email: normalizedEmail,
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Send confirmation email
|
||||
try {
|
||||
const emailHtml = await renderWaitlistConfirmationEmail(normalizedEmail)
|
||||
const subject = getEmailSubject('waitlist-confirmation')
|
||||
|
||||
await sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('Error sending confirmation email:', emailError)
|
||||
// Continue even if email fails - user is still on waitlist
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Successfully added to waitlist',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding to waitlist:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while adding to waitlist',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all waitlist entries with pagination and search
|
||||
export async function getWaitlistEntries(
|
||||
page = 1,
|
||||
limit = 20,
|
||||
status?: WaitlistStatus | 'all',
|
||||
search?: string
|
||||
) {
|
||||
try {
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
// Build query conditions
|
||||
let whereCondition
|
||||
|
||||
// First, determine if we need to apply status filter
|
||||
const shouldFilterByStatus = status && status !== 'all'
|
||||
|
||||
console.log('Service: Filtering by status:', shouldFilterByStatus ? status : 'No status filter')
|
||||
|
||||
// Now build the conditions
|
||||
if (shouldFilterByStatus && search && search.trim()) {
|
||||
// Both status and search
|
||||
console.log('Service: Applying status + search filter:', status)
|
||||
whereCondition = and(
|
||||
eq(waitlist.status, status as string),
|
||||
like(waitlist.email, `%${search.trim()}%`)
|
||||
)
|
||||
} else if (shouldFilterByStatus) {
|
||||
// Only status
|
||||
console.log('Service: Applying status filter only:', status)
|
||||
whereCondition = eq(waitlist.status, status as string)
|
||||
} else if (search && search.trim()) {
|
||||
// Only search
|
||||
console.log('Service: Applying search filter only')
|
||||
whereCondition = like(waitlist.email, `%${search.trim()}%`)
|
||||
} else {
|
||||
console.log('Service: No filters applied, showing all entries')
|
||||
}
|
||||
|
||||
// Log what filter is being applied
|
||||
console.log('Service: Where condition:', whereCondition ? 'applied' : 'none')
|
||||
|
||||
// Get entries with conditions
|
||||
let entries = []
|
||||
if (whereCondition) {
|
||||
entries = await db
|
||||
.select()
|
||||
.from(waitlist)
|
||||
.where(whereCondition)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy(desc(waitlist.createdAt))
|
||||
} else {
|
||||
// Get all entries
|
||||
entries = await db
|
||||
.select()
|
||||
.from(waitlist)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy(desc(waitlist.createdAt))
|
||||
}
|
||||
|
||||
// Get total count for pagination with same conditions
|
||||
let countResult = []
|
||||
if (whereCondition) {
|
||||
countResult = await db.select({ value: count() }).from(waitlist).where(whereCondition)
|
||||
} else {
|
||||
countResult = await db.select({ value: count() }).from(waitlist)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Service: Found ${entries.length} entries with ${status === 'all' ? 'all statuses' : `status=${status}`}, total: ${countResult[0]?.value || 0}`
|
||||
)
|
||||
|
||||
return {
|
||||
entries,
|
||||
total: countResult[0]?.value || 0,
|
||||
page,
|
||||
limit,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting waitlist entries:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Approve a user from the waitlist and send approval email
|
||||
export async function approveWaitlistUser(
|
||||
email: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { user, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User not found in waitlist',
|
||||
}
|
||||
}
|
||||
|
||||
if (user.status === 'approved') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User already approved',
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
type: 'waitlist-approval',
|
||||
expiresIn: '7d',
|
||||
})
|
||||
|
||||
// Generate signup link with token
|
||||
const signupLink = `${process.env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
|
||||
|
||||
// Send approval email
|
||||
try {
|
||||
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
|
||||
const subject = getEmailSubject('waitlist-approval')
|
||||
|
||||
await sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
})
|
||||
} 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',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving waitlist user:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while approving user',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reject a user from the waitlist
|
||||
export async function rejectWaitlistUser(
|
||||
email: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { user, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User not found in waitlist',
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to rejected
|
||||
await db
|
||||
.update(waitlist)
|
||||
.set({
|
||||
status: 'rejected',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(waitlist.email, normalizedEmail))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'User rejected',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting waitlist user:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while rejecting user',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a user is approved
|
||||
export async function isUserApproved(email: string): Promise<boolean> {
|
||||
try {
|
||||
const { user } = await findUserByEmail(email)
|
||||
return !!user && user.status === 'approved'
|
||||
} catch (error) {
|
||||
console.error('Error checking if user is approved:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Verify waitlist token
|
||||
export async function verifyWaitlistToken(
|
||||
token: string
|
||||
): Promise<{ valid: boolean; email?: string }> {
|
||||
try {
|
||||
// Verify token
|
||||
const decoded = await verifyToken(token)
|
||||
|
||||
if (!decoded || decoded.type !== 'waitlist-approval') {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
// Check if user is in the approved waitlist
|
||||
const isApproved = await isUserApproved(decoded.email)
|
||||
|
||||
if (!isApproved) {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
email: decoded.email,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying waitlist token:', error)
|
||||
return { valid: false }
|
||||
}
|
||||
}
|
||||
53
sim/lib/waitlist/token.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { jwtVerify, SignJWT } from 'jose'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
interface TokenPayload {
|
||||
email: string
|
||||
type: 'waitlist-approval' | 'password-reset'
|
||||
expiresIn: string
|
||||
}
|
||||
|
||||
interface DecodedToken {
|
||||
email: string
|
||||
type: string
|
||||
jti: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
// Get JWT secret from environment variables
|
||||
const getJwtSecret = () => {
|
||||
const secret = process.env.JWT_SECRET
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET environment variable is not set')
|
||||
}
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JWT token
|
||||
*/
|
||||
export async function createToken({ email, type, expiresIn }: TokenPayload): Promise<string> {
|
||||
const jwt = await new SignJWT({ email, type })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiresIn)
|
||||
.setJti(nanoid())
|
||||
.sign(getJwtSecret())
|
||||
|
||||
return jwt
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export async function verifyToken(token: string): Promise<DecodedToken | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwtSecret())
|
||||
|
||||
return payload as unknown as DecodedToken
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSessionCookie } from 'better-auth/cookies'
|
||||
import { verifyToken } from './lib/waitlist/token'
|
||||
|
||||
// Environment flag to check if we're in development mode
|
||||
const isDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
// Check if the path is exactly /w
|
||||
@@ -7,17 +11,72 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL('/w/1', request.url))
|
||||
}
|
||||
|
||||
const sessionCookie = getSessionCookie(request)
|
||||
if (!sessionCookie) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
// Handle protected routes that require authentication
|
||||
if (request.nextUrl.pathname.startsWith('/w/') || request.nextUrl.pathname === '/w') {
|
||||
const sessionCookie = getSessionCookie(request)
|
||||
if (!sessionCookie) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
// Add session expiration validation if better-auth provides this functionality
|
||||
// This would depend on the implementation of better-auth
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Skip waitlist protection for development environment
|
||||
if (isDevelopment) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Handle waitlist protection for login and signup in production
|
||||
if (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup') {
|
||||
// Check for a waitlist token in the URL
|
||||
const waitlistToken = request.nextUrl.searchParams.get('token')
|
||||
|
||||
// Validate the token if present
|
||||
if (waitlistToken) {
|
||||
try {
|
||||
const decodedToken = await verifyToken(waitlistToken)
|
||||
|
||||
// If token is valid and is a waitlist approval token
|
||||
if (decodedToken && decodedToken.type === 'waitlist-approval') {
|
||||
// Check token expiration
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (decodedToken.exp > now) {
|
||||
// Token is valid and not expired, allow access
|
||||
return NextResponse.next()
|
||||
}
|
||||
}
|
||||
|
||||
// Token is invalid, expired, or wrong type - redirect to home
|
||||
if (request.nextUrl.pathname === '/signup') {
|
||||
return NextResponse.redirect(new URL('/', request.url))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token validation error:', error)
|
||||
// In case of error, redirect signup attempts to home
|
||||
if (request.nextUrl.pathname === '/signup') {
|
||||
return NextResponse.redirect(new URL('/', request.url))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no token for signup, redirect to home
|
||||
if (request.nextUrl.pathname === '/signup') {
|
||||
return NextResponse.redirect(new URL('/', request.url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// TODO: Add protected routes
|
||||
// Update matcher to include admin routes
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/w', // Match exactly /w
|
||||
'/w/:path*', // Keep existing matcher for protected routes
|
||||
'/w/:path*', // Match protected routes
|
||||
'/login',
|
||||
'/signup',
|
||||
],
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
BIN
sim/public/static/discord-icon.png
Normal file
|
After Width: | Height: | Size: 708 B |
BIN
sim/public/static/github-icon.png
Normal file
|
After Width: | Height: | Size: 850 B |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
BIN
sim/public/static/x-icon.png
Normal file
|
After Width: | Height: | Size: 1019 B |
|
Before Width: | Height: | Size: 17 KiB |