diff --git a/README.md b/README.md index 1f45dcd63a..bc55f10d57 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

- Sim Studio Logo + Sim Studio Logo

License: Apache-2.0 - Discord + Discord Twitter PRs welcome support diff --git a/sim/app/(auth)/signup/signup-form.tsx b/sim/app/(auth)/signup/signup-form.tsx index 5197f525fd..04551454c3 100644 --- a/sim/app/(auth)/signup/signup-form.tsx +++ b/sim/app/(auth)/signup/signup-form.tsx @@ -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([]) 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)} />

@@ -275,3 +283,29 @@ export default function SignupPage({ ) } + +export default function SignupPage({ + githubAvailable, + googleAvailable, + isProduction, +}: { + githubAvailable: boolean + googleAvailable: boolean + isProduction: boolean +}) { + return ( + +
+
+ } + > + +
+ ) +} diff --git a/sim/app/(landing)/components/nav-client.tsx b/sim/app/(landing)/components/nav-client.tsx index 8dc4ea6174..0c881c33fe 100644 --- a/sim/app/(landing)/components/nav-client.tsx +++ b/sim/app/(landing)/components/nav-client.tsx @@ -59,7 +59,7 @@ export default function NavClient({ children }: { children: React.ReactNode }) { ('idle') + const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'exists' | 'ratelimited'>( + 'idle' + ) + const [errorMessage, setErrorMessage] = useState('') + const [retryAfter, setRetryAfter] = useState(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 (
setEmail(e.target.value)} - disabled={isSubmitting} + disabled={isSubmitting || status === 'ratelimited'} /> diff --git a/sim/app/admin/page.tsx b/sim/app/admin/page.tsx new file mode 100644 index 0000000000..ec2c099cb6 --- /dev/null +++ b/sim/app/admin/page.tsx @@ -0,0 +1,11 @@ +import PasswordAuth from './password-auth' + +export default function AdminPage() { + return ( + +
+

Admin Page

+
+
+ ) +} diff --git a/sim/app/admin/password-auth.tsx b/sim/app/admin/password-auth.tsx new file mode 100644 index 0000000000..9f1370d30b --- /dev/null +++ b/sim/app/admin/password-auth.tsx @@ -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(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 ( +
+

Checking authentication...

+
+ ) + } + + if (isAuthorized) { + return <>{children} + } + + return ( +
+ + +
+
+ +
+
+ Admin Access + + Enter your admin password to continue + +
+ + +
+ setPassword(e.target.value)} + placeholder="Enter admin password" + autoComplete="off" + className="w-full" + autoFocus + /> + {error &&

{error}

} +
+ + +
+
+
+ ) +} diff --git a/sim/app/admin/waitlist/page.tsx b/sim/app/admin/waitlist/page.tsx new file mode 100644 index 0000000000..f61f4c5ac6 --- /dev/null +++ b/sim/app/admin/waitlist/page.tsx @@ -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 ( + +
+
+

Waitlist Management

+

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

+
+ +
+ +
+
+
+ ) +} diff --git a/sim/app/admin/waitlist/stores/store.ts b/sim/app/admin/waitlist/stores/store.ts new file mode 100644 index 0000000000..4b417bce81 --- /dev/null +++ b/sim/app/admin/waitlist/stores/store.ts @@ -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 + + // 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 + 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((set, get) => ({ + // Core data + entries: [], + filteredEntries: [], + loading: true, + error: null, + + // Filters + status: 'all', + searchTerm: '', + + // Pagination + page: 1, + totalEntries: 0, + + // Selection + selectedIds: new Set(), + + // 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, + }) + } + }, +})) diff --git a/sim/app/admin/waitlist/waitlist-table.tsx b/sim/app/admin/waitlist/waitlist-table.tsx new file mode 100644 index 0000000000..a16dc41b3b --- /dev/null +++ b/sim/app/admin/waitlist/waitlist-table.tsx @@ -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) => ( + +) + +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(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) => { + 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 ( +
+ +
+ ) + } + + return ( +
+ {/* Filter bar - similar to logs.tsx */} +
+
+ handleStatusChange('all')} + icon={} + label="All" + className={ + status === 'all' + ? 'bg-blue-100 text-blue-900 hover:bg-blue-200 hover:text-blue-900' + : '' + } + /> + handleStatusChange('pending')} + icon={} + label="Pending" + className={ + status === 'pending' + ? 'bg-amber-100 text-amber-900 hover:bg-amber-200 hover:text-amber-900' + : '' + } + /> + handleStatusChange('approved')} + icon={} + label="Approved" + className={ + status === 'approved' + ? 'bg-green-100 text-green-900 hover:bg-green-200 hover:text-green-900' + : '' + } + /> + handleStatusChange('rejected')} + icon={} + label="Rejected" + className={ + status === 'rejected' + ? 'bg-red-100 text-red-900 hover:bg-red-200 hover:text-red-900' + : '' + } + /> +
+
+ + {/* Search and bulk actions bar */} +
+
+ + +
+ +
+ + + {selectedIds.size > 0 && ( + <> + + {selectedIds.size} selected + + + + + + + + + Approve all selected users and send them access emails + + + + + + + + + + Reject all selected users + + + + )} +
+
+ + {/* Error alert */} + {error && ( + + + + {error} + + + + )} + + {/* Loading skeleton */} + {loading ? ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ) : filteredEntries.length === 0 ? ( +
+
+ +
+

No entries found

+

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

+
+ ) : ( + <> + {/* Table */} +
+ + + + +
+ +
+
+ Email + Joined + Status + Actions +
+
+ + {filteredEntries.map((entry) => ( + + + + + {entry.email} + + + + + {formatDate(entry.createdAt)} + + {getDetailedTimeTooltip(entry.createdAt)} + + + + + + {entry.status} + + + +
+ {entry.status !== 'approved' && ( + + + + + + Approve user and send access email + + + )} + + {entry.status !== 'rejected' && entry.status !== 'approved' && ( + + + + + + Reject user + + + )} + + + + + + + Email user in Gmail + + +
+
+
+ ))} +
+
+
+ + {/* Pagination */} + {!searchTerm && ( +
+ + + Page {page} of {Math.ceil(totalEntries / 50) || 1} +  •  + {totalEntries} total entries + + +
+ )} + + )} +
+ ) +} diff --git a/sim/app/api/admin/waitlist/bulk/route.ts b/sim/app/api/admin/waitlist/bulk/route.ts new file mode 100644 index 0000000000..5352fefa33 --- /dev/null +++ b/sim/app/api/admin/waitlist/bulk/route.ts @@ -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 } + ) + } +} diff --git a/sim/app/api/admin/waitlist/route.ts b/sim/app/api/admin/waitlist/route.ts new file mode 100644 index 0000000000..b33e76307d --- /dev/null +++ b/sim/app/api/admin/waitlist/route.ts @@ -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 } + ) + } +} diff --git a/sim/app/api/auth/session/route.ts b/sim/app/api/auth/session/route.ts new file mode 100644 index 0000000000..1a374a793c --- /dev/null +++ b/sim/app/api/auth/session/route.ts @@ -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 }) + } +} diff --git a/sim/app/api/waitlist/route.ts b/sim/app/api/waitlist/route.ts index dc4781da02..135aee3f6b 100644 --- a/sim/app/api/waitlist/route.ts +++ b/sim/app/api/waitlist/route.ts @@ -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 } + ) } } diff --git a/sim/components/emails/footer.tsx b/sim/components/emails/footer.tsx new file mode 100644 index 0000000000..05e84259a3 --- /dev/null +++ b/sim/components/emails/footer.tsx @@ -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 ( + +
+ + + + + + + +
+ + + + + + +
+ + X + + + + Discord + + + + GitHub + +
+
+ + © {new Date().getFullYear()} Sim Studio, All Rights Reserved +
+ If you have any questions, please contact us at{' '} + + help@simstudio.ai + +
+ + + + +
+

+ + Privacy Policy + {' '} + •{' '} + + Terms of Service + +

+
+
+
+
+ ) +} + +export default EmailFooter diff --git a/sim/components/emails/otp-verification-email.tsx b/sim/components/emails/otp-verification-email.tsx index 7944484a44..46aceb27f5 100644 --- a/sim/components/emails/otp-verification-email.tsx +++ b/sim/components/emails/otp-verification-email.tsx @@ -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 = ({ {getSubjectByType(type)} -
- Sim Studio +
+ + + Sim Studio + +
@@ -95,52 +92,7 @@ export const OTPVerificationEmail = ({
-
- - - - X - - - - - Discord - - - - - © {new Date().getFullYear()} Sim Studio, All Rights Reserved -
- If you have any questions, please contact us at support@simstudio.ai -
-
+ ) diff --git a/sim/components/emails/render-email.ts b/sim/components/emails/render-email.ts index fe5bae516f..b4bfebd708 100644 --- a/sim/components/emails/render-email.ts +++ b/sim/components/emails/render-email.ts @@ -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 { + return await renderAsync(WaitlistConfirmationEmail({ email })) +} + +/** + * Renders the waitlist approval email to HTML + */ +export async function renderWaitlistApprovalEmail( + email: string, + signupLink: string +): Promise { + 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' } diff --git a/sim/components/emails/reset-password-email.tsx b/sim/components/emails/reset-password-email.tsx index fba65f95a1..7ed7850272 100644 --- a/sim/components/emails/reset-password-email.tsx +++ b/sim/components/emails/reset-password-email.tsx @@ -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 ( Reset your Sim Studio password -
- Sim Studio +
+ + + Sim Studio + +
+
@@ -62,78 +57,40 @@ export const ResetPasswordEmail = ({
+
Hello {username}, - 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. -
- - Reset Password - -
+ + Reset Your Password + If you did not request a password reset, please ignore this email or contact support if you have concerns. - - For security reasons, this password reset link will expire in 24 hours. - Best regards,
The Sim Studio Team
+ + This email was sent on {format(updatedDate, 'MMMM do, yyyy')} because a password reset + was requested for your account. +
-
- - - - X - - - - - Discord - - - - - © {new Date().getFullYear()} Sim Studio, All Rights Reserved -
- If you have any questions, please contact us at support@simstudio.ai -
-
+ ) diff --git a/sim/components/emails/waitlist-approval-email.tsx b/sim/components/emails/waitlist-approval-email.tsx new file mode 100644 index 0000000000..9c1bb715fa --- /dev/null +++ b/sim/components/emails/waitlist-approval-email.tsx @@ -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 ( + + + + You've Been Approved to Join Sim Studio! + +
+ + + Sim Studio + + +
+ +
+ + + + + +
+ +
+ Great news! + + 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. + + + Your email ({email}) has been approved. Click the button below to create your account + and start using Sim Studio today: + + + Create Your Account + + + 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. + + + Best regards, +
+ The Sim Studio Team +
+
+
+ + + + + ) +} + +export default WaitlistApprovalEmail diff --git a/sim/components/emails/waitlist-confirmation-email.tsx b/sim/components/emails/waitlist-confirmation-email.tsx new file mode 100644 index 0000000000..153b8d9555 --- /dev/null +++ b/sim/components/emails/waitlist-confirmation-email.tsx @@ -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 ( + + + + Welcome to the Sim Studio Waitlist! + +
+ + + Sim Studio + + +
+ +
+ + + + + +
+ +
+ Welcome to the Sim Studio Waitlist! + + 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. + + + Want to get access sooner? Tell us about your use case! Schedule a + 15-minute call with our team to discuss how you plan to use Sim Studio. + + + Schedule a Call + + + We're excited to help you build, test, and optimize your agentic workflows. + + + Best regards, +
+ The Sim Studio Team +
+
+
+ + + + + ) +} + +export default WaitlistConfirmationEmail diff --git a/sim/components/ui/table.tsx b/sim/components/ui/table.tsx new file mode 100644 index 0000000000..f0f8a007e4 --- /dev/null +++ b/sim/components/ui/table.tsx @@ -0,0 +1,90 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +) +Table.displayName = 'Table' + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = 'TableHeader' + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = 'TableBody' + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', className)} + {...props} + /> +)) +TableFooter.displayName = 'TableFooter' + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableRow.displayName = 'TableRow' + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = 'TableHead' + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = 'TableCell' + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = 'TableCaption' + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } diff --git a/sim/db/index.ts b/sim/db/index.ts index 4a4bf4026d..974984d81e 100644 --- a/sim/db/index.ts +++ b/sim/db/index.ts @@ -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 - -// 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) diff --git a/sim/lib/mailer.ts b/sim/lib/mailer.ts new file mode 100644 index 0000000000..317c819386 --- /dev/null +++ b/sim/lib/mailer.ts @@ -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 { + 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', + } + } +} diff --git a/sim/lib/waitlist/rate-limiter.ts b/sim/lib/waitlist/rate-limiter.ts new file mode 100644 index 0000000000..afe4450404 --- /dev/null +++ b/sim/lib/waitlist/rate-limiter.ts @@ -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 } +} diff --git a/sim/lib/waitlist/service.ts b/sim/lib/waitlist/service.ts new file mode 100644 index 0000000000..922fb9207d --- /dev/null +++ b/sim/lib/waitlist/service.ts @@ -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 { + 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 } + } +} diff --git a/sim/lib/waitlist/token.ts b/sim/lib/waitlist/token.ts new file mode 100644 index 0000000000..d95956423c --- /dev/null +++ b/sim/lib/waitlist/token.ts @@ -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 { + 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 { + try { + const { payload } = await jwtVerify(token, getJwtSecret()) + + return payload as unknown as DecodedToken + } catch (error) { + console.error('Error verifying token:', error) + return null + } +} diff --git a/sim/middleware.ts b/sim/middleware.ts index 293cddf297..980780cd79 100644 --- a/sim/middleware.ts +++ b/sim/middleware.ts @@ -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', ], } diff --git a/sim/public/discord-icon.png b/sim/public/discord-icon.png deleted file mode 100644 index 45547f7c04..0000000000 Binary files a/sim/public/discord-icon.png and /dev/null differ diff --git a/sim/public/static/discord-icon.png b/sim/public/static/discord-icon.png new file mode 100644 index 0000000000..0b9374d19a Binary files /dev/null and b/sim/public/static/discord-icon.png differ diff --git a/sim/public/static/github-icon.png b/sim/public/static/github-icon.png new file mode 100644 index 0000000000..8fc16c9cd2 Binary files /dev/null and b/sim/public/static/github-icon.png differ diff --git a/sim/public/sim.png b/sim/public/static/sim.png similarity index 100% rename from sim/public/sim.png rename to sim/public/static/sim.png diff --git a/sim/public/static/x-icon.png b/sim/public/static/x-icon.png new file mode 100644 index 0000000000..90121e02fe Binary files /dev/null and b/sim/public/static/x-icon.png differ diff --git a/sim/public/x-icon.png b/sim/public/x-icon.png deleted file mode 100644 index dbf2e17734..0000000000 Binary files a/sim/public/x-icon.png and /dev/null differ