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
This commit is contained in:
Waleed Latif
2025-03-28 14:56:54 -07:00
committed by GitHub
parent c048a3c1ab
commit a11cbd92af
32 changed files with 2637 additions and 253 deletions

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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"

View File

@@ -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) {
// 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
View File

@@ -0,0 +1,11 @@
import PasswordAuth from './password-auth'
export default function AdminPage() {
return (
<PasswordAuth>
<div>
<h1>Admin Page</h1>
</div>
</PasswordAuth>
)
}

View 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>
)
}

View 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>
)
}

View 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,
})
}
},
}))

View 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}
&nbsp;&nbsp;
{totalEntries} total entries
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={page >= Math.ceil(totalEntries / 50)}
>
Next
</Button>
</div>
)}
</>
)}
</div>
)
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 })
}
}

View File

@@ -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
// 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',
})
return NextResponse.json({ message: 'Successfully joined waitlist' }, { status: 200 })
} 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 }
)
}
}

View 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

View File

@@ -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',
}}
>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={`${baseUrl}/sim.png`}
src={`${baseUrl}/static/sim.png`}
width="114"
alt="Sim Studio"
style={{
display: 'inline-block',
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>
)

View File

@@ -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'
}

View File

@@ -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',
}}
>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={`${baseUrl}/sim.png`}
src={`${baseUrl}/static/sim.png`}
width="114"
alt="Sim Studio"
style={{
display: 'inline-block',
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 href={resetLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Reset Your Password</Text>
</Link>
</Section>
<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>
</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',
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
© {new Date().getFullYear()} Sim Studio, All Rights Reserved
<br />
If you have any questions, please contact us at support@simstudio.ai
This email was sent on {format(updatedDate, 'MMMM do, yyyy')} because a password reset
was requested for your account.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)

View 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

View 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

View 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 }

View File

@@ -1,44 +1,6 @@
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'
// 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!
@@ -46,14 +8,5 @@ if (!isLocalStorage) {
// 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 }
export const db = drizzle(client)

55
sim/lib/mailer.ts Normal file
View 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',
}
}
}

View 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
View 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
View 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
}
}

View File

@@ -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))
}
// 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()
}
// TODO: Add protected routes
// 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()
}
// 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',
],
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB