From a11cbd92afd511e2f5256113918e8569b8f01363 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Mar 2025 14:56:54 -0700 Subject: [PATCH] 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 --- README.md | 4 +- sim/app/(auth)/signup/signup-form.tsx | 48 +- sim/app/(landing)/components/nav-client.tsx | 2 +- .../(landing)/components/waitlist-form.tsx | 53 +- sim/app/admin/page.tsx | 11 + sim/app/admin/password-auth.tsx | 99 +++ sim/app/admin/waitlist/page.tsx | 27 + sim/app/admin/waitlist/stores/store.ts | 187 +++++ sim/app/admin/waitlist/waitlist-table.tsx | 679 ++++++++++++++++++ sim/app/api/admin/waitlist/bulk/route.ts | 131 ++++ sim/app/api/admin/waitlist/route.ts | 199 +++++ sim/app/api/auth/session/route.ts | 50 ++ sim/app/api/waitlist/route.ts | 90 ++- sim/components/emails/footer.tsx | 118 +++ .../emails/otp-verification-email.tsx | 78 +- sim/components/emails/render-email.ts | 31 +- .../emails/reset-password-email.tsx | 113 +-- .../emails/waitlist-approval-email.tsx | 89 +++ .../emails/waitlist-confirmation-email.tsx | 85 +++ sim/components/ui/table.tsx | 90 +++ sim/db/index.ts | 63 +- sim/lib/mailer.ts | 55 ++ sim/lib/waitlist/rate-limiter.ts | 154 ++++ sim/lib/waitlist/service.ts | 312 ++++++++ sim/lib/waitlist/token.ts | 53 ++ sim/middleware.ts | 69 +- sim/public/discord-icon.png | Bin 17453 -> 0 bytes sim/public/static/discord-icon.png | Bin 0 -> 708 bytes sim/public/static/github-icon.png | Bin 0 -> 850 bytes sim/public/{ => static}/sim.png | Bin sim/public/static/x-icon.png | Bin 0 -> 1019 bytes sim/public/x-icon.png | Bin 17708 -> 0 bytes 32 files changed, 2637 insertions(+), 253 deletions(-) create mode 100644 sim/app/admin/page.tsx create mode 100644 sim/app/admin/password-auth.tsx create mode 100644 sim/app/admin/waitlist/page.tsx create mode 100644 sim/app/admin/waitlist/stores/store.ts create mode 100644 sim/app/admin/waitlist/waitlist-table.tsx create mode 100644 sim/app/api/admin/waitlist/bulk/route.ts create mode 100644 sim/app/api/admin/waitlist/route.ts create mode 100644 sim/app/api/auth/session/route.ts create mode 100644 sim/components/emails/footer.tsx create mode 100644 sim/components/emails/waitlist-approval-email.tsx create mode 100644 sim/components/emails/waitlist-confirmation-email.tsx create mode 100644 sim/components/ui/table.tsx create mode 100644 sim/lib/mailer.ts create mode 100644 sim/lib/waitlist/rate-limiter.ts create mode 100644 sim/lib/waitlist/service.ts create mode 100644 sim/lib/waitlist/token.ts delete mode 100644 sim/public/discord-icon.png create mode 100644 sim/public/static/discord-icon.png create mode 100644 sim/public/static/github-icon.png rename sim/public/{ => static}/sim.png (100%) create mode 100644 sim/public/static/x-icon.png delete mode 100644 sim/public/x-icon.png 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 45547f7c048de2e5beb1977a2fdb376aca8ff069..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17453 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 diff --git a/sim/public/static/discord-icon.png b/sim/public/static/discord-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0b9374d19a71f293154c6e93a5ed655a7e6636da GIT binary patch literal 708 zcmV;#0z3VQP)Oc@38}kJpF2NmyRB)XNl3PKb0zf>3_=)p_5Fp)41xY$^2XQ(`2XP7h0EEOVXCg0J z>|Mu^VkI9O$y)FF%N==wh*`8-{U>u6r~v3?I!C7O)d0;cmC%#c!msfVq`Q#m9V6ZUO8GCVR%K zL*IiCA}&=TOcfi8-+kla3}>}k-KjpLA`pB3Mg`z-I9$zUvp^n8MIiRR5Fme2bDu<@ zNbd#8)y})+6l}|}(3tTD~$b%-S1oV2n8tF4Xf?=DJ2!&=&dd!)F zwt@W)H5_|59M(j~l9ahHQd(fVF`Z8RxEpjj9YP^$CTNe~v_z-d?S`;>R?UZPaM66^ zfhjHWht!BBlZh`6Ad&tjNfrMMU&U;d(1ImJco{%9kA;mk#m@G#M>!f-AM4d0fQ%cUc-88)G9+mX0% z^!t4e-&Fq4RgJ+!aEAS2!(j0>XDsPYg0000CrGl-f}CK&Zgw;$7?1!VHX$2?_~!(4PC(`akOg~kg6SI!8xRt| zc}3i)aof-|Gtx+tCs}P*m+j{&SGkD(cZ4Vaip8RV&!(+bt8q9S9>-#_QMp_m(~H!>ZSsMG1aPYOWHM=^YtnJB{1Dko0H#hXm&+cy zaS8?-R?F6EwO5@&ME9dmD5T(Do*1LxVB+8I?(Xd2aQLoL2x&HFKlkVl0_)v38jXQ0 z56c3+;ZSZnW0=s!=N2t3`g_4Tn4g``=T~lh^m`(Db`X^X@cAQsgqXwpy%n`^DOSUasHzzm^eN}PVV2OwjS8Y4Okvs5ZQ zS1OgQ?v4utLm37I1gv){%V0IrJ(ll4g1mU4-~^r(;;-((HVQJCOdP)4F*jc#`1FcS z0Pakmq=O+rT{#JAYXRi)8+kHL@Pkf=C;k_Uh2iwK1mGJ_wu1$Zeq8*l0h!?;z#$|n z!UA9DFThP|O8|bc6}kqU2pr;CmhltrAuyZGRB@c(*N_52WWEPkesOW(%>$iIXRJ!K z)9JKJ0fCc#mu0+%yKs^7fG6f*!Uv>ORf5ujt%f;m%*`m)8|)Br4@4^dDHNZWE!v}~ zfNc7xc3xEF7jFt{CD6SV8!KJs&H;=a3-Aw2-dKS*=G%&KQv5d1Oc3njzIYaA8Fag7 zWzrWgmRuoup5tgVTE=H{I$rSn{M?2wldk(=smd%N(>_Q%pG!24T&0}L3 z$64|L_}v2Ol!ic3RhnsQLhxFmmItKF*VKsTav0OE3)2+=a19%O8tu848Iz?LZE cKJoA4H<=i8tPuQkV*mgE07*qoM6N<$f}Bu(zyJUM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..90121e02fea5abc2415858fad284a010a67526aa GIT binary patch literal 1019 zcmV!LYY$OQkudZ zJbvX{bEmA%l67{&H{;RWNq4*Z-FJIyA>J9*-riouvaD3xhqi6!PfkueF#`7Y_jA18 z>2#_W7Z(n-$GY9Fhv#kFBJFm2BbUof;a3$y;Nalk76vnX3;|E6whd2QII(T8R;pgF zABiD=$d7d6Wb6F={MmO#HLw+rcr2+tCa%HLDkdaB|GoI{{r1_}nIjC0V{T3}evE(v zW43BEn@uw4`7dnyE3tEVdHKK@Li-5LPKv?C)4FlsMmOFIh0(W1Rp(So5(#!#wOt6fJJf8@q#4xC*R5wP_E zIvy~5$A<0^Y0_~V(gR;81FEwj##7ulwsLvU$+5!*Tdh`Bi?%1i5Qcm`!1z5k4phl_ zT<)-DaIZuVHwJ+B!pFXWhj3KnDn< z2P}T0`Y?J+%rHIl?X5iPv3bJC0Rpxmhlk2sv@N}~E%k7$eVz|H>w}Pp`=C;(ye=*- z7GNxc?gw&WetupQi^Zo>sZ^Pro&5}ERANf5tgPI*uKOws5z7F9^nk6{RDH^FQF_3A z5qq(5#4?~zE|-7L%*=d*v1#h#nFcf8LHS>>@Pwgt@y5^>F!Tb3Dkke1F`zI$KF-CJ zlxM$B4?Kg$!I%NchK)v}EP^@y6QWHb+5+i;GX$)f=)sr)WWzw3RY0^^q~u;wq7yT$ z9*i7-0lIi}N*|aLj>->|euc~r7*F&2Vk8G-VG&qFRyCA}TshR!18viw3_7#}5Ykpe zRnrrrR>VxIk}WKQl5;~jKvfMMdP)scHAK0VAF_&aT@UI;)e(9MXg{&=Pw{rH6`m#S z0YLp8^1us>t;(V4xw*N*)zy`^u(0q$%b7?CPh^>afL-x$p+T)gY=*mrGe5Ms3P(y(|<%V`*HTDaQQ=}J=$CBQ@=>{H6YLW zI$sczznz|*8s&5X^g#DQgXv*X1~jdG>fflAG;Z`K!W26@I}WyOEABfg+s35g;k3n* phX><%F(T*)V(U!)L#D)g;~(UyXB6P)0L}ma002ovPDHLkV1gth+Q0w+ literal 0 HcmV?d00001 diff --git a/sim/public/x-icon.png b/sim/public/x-icon.png deleted file mode 100644 index dbf2e177341a215197fe89bc9cc68f1e57de224f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17708 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