feat(waitlist): remove waitlist, update login/signup/verify styling to match landing, remove OTP in dev/docker (#377)

* removed waitlist on landing

* remove OTP in dev or docker, remove invite members from sidebar in dev/docker

* modified signup, login, verify, and reset password to match landing

* add README for npm package

* acknowledged PR comments

* restore cmd+enter functionality
This commit is contained in:
Waleed Latif
2025-05-18 23:04:22 -07:00
committed by GitHub
parent f2894e645e
commit fd82b99c5c
16 changed files with 602 additions and 638 deletions

View File

@@ -99,7 +99,7 @@ export function SocialLoginButtons({
const githubButton = (
<Button
variant="outline"
className="w-full"
className="w-full bg-neutral-900 border-neutral-700 text-white hover:bg-neutral-800 hover:text-white"
disabled={!githubAvailable || isGithubLoading}
onClick={signInWithGithub}
>
@@ -111,7 +111,7 @@ export function SocialLoginButtons({
const googleButton = (
<Button
variant="outline"
className="w-full"
className="w-full bg-neutral-900 border-neutral-700 text-white hover:bg-neutral-800 hover:text-white"
disabled={!googleAvailable || isGoogleLoading}
onClick={signInWithGoogle}
>
@@ -129,12 +129,12 @@ export function SocialLoginButtons({
<TooltipTrigger asChild>
<div>{githubButton}</div>
</TooltipTrigger>
<TooltipContent>
<TooltipContent className="bg-neutral-800 text-white border-neutral-700">
<p>
GitHub login requires OAuth credentials to be configured. Add the following
environment variables:
</p>
<ul className="mt-2 text-xs space-y-1">
<ul className="mt-2 text-xs space-y-1 text-neutral-300">
<li> GITHUB_CLIENT_ID</li>
<li> GITHUB_CLIENT_SECRET</li>
</ul>
@@ -153,12 +153,12 @@ export function SocialLoginButtons({
<TooltipTrigger asChild>
<div>{googleButton}</div>
</TooltipTrigger>
<TooltipContent>
<TooltipContent className="bg-neutral-800 text-white border-neutral-700">
<p>
Google login requires OAuth credentials to be configured. Add the following
environment variables:
</p>
<ul className="mt-2 text-xs space-y-1">
<ul className="mt-2 text-xs space-y-1 text-neutral-300">
<li> GOOGLE_CLIENT_ID</li>
<li> GOOGLE_CLIENT_SECRET</li>
</ul>
@@ -169,7 +169,7 @@ export function SocialLoginButtons({
}
return (
<div className="grid gap-2">
<div className="grid gap-3">
{renderGithubButton()}
{renderGoogleButton()}
</div>

View File

@@ -0,0 +1,41 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { GridPattern } from '../(landing)/components/grid-pattern'
import { NotificationList } from '../w/[id]/components/notifications/notifications'
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-[#0C0C0C] text-white relative font-geist-sans flex flex-col">
{/* Background pattern */}
<GridPattern
x={-5}
y={-5}
className="stroke-[#ababab]/5 absolute inset-0 z-0"
width={90}
height={90}
aria-hidden="true"
/>
{/* Header */}
<div className="px-6 py-8 relative z-10">
<div className="max-w-7xl mx-auto">
<Link href="/" className="inline-flex">
<Image src="/sim.svg" alt="Sim Logo" width={42} height={42} />
</Link>
</div>
</div>
{/* Content */}
<div className="flex-1 flex items-center justify-center px-4 pb-6 relative z-10">
<div className="w-full max-w-md">{children}</div>
</div>
{/* Notifications */}
<div className="fixed bottom-4 right-4 z-50">
<NotificationList />
</div>
</main>
)
}

View File

@@ -5,23 +5,13 @@ import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { useNotificationStore } from '@/stores/notifications/store'
import { RequestResetForm } from '@/app/(auth)/components/reset-password-form'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
const logger = createLogger('LoginForm')
@@ -39,12 +29,6 @@ const validateCallbackUrl = (url: string): boolean => {
return true
}
// Add other trusted domains if needed
// const trustedDomains = ['trusted-domain.com']
// if (trustedDomains.some(domain => url.startsWith(`https://${domain}`))) {
// return true
// }
return false
} catch (error) {
logger.error('Error validating callback URL:', { error, url })
@@ -104,6 +88,19 @@ export default function LoginPage({
}
}, [searchParams])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && forgotPasswordOpen) {
handleForgotPassword()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [forgotPasswordEmail, forgotPasswordOpen])
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
@@ -259,123 +256,155 @@ export default function LoginPage({
}
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
{/* Ensure NotificationList is always rendered */}
<NotificationList />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h1 className="text-2xl font-bold text-center mb-8">Sim Studio</h1>
<Card className="w-full">
<CardHeader>
<CardTitle>Welcome back</CardTitle>
<CardDescription>
{isInviteFlow
? 'Sign in to continue to the invitation'
: 'Enter your credentials to access your account'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6">
{mounted && (
<SocialLoginButtons
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
callbackURL={callbackUrl}
isProduction={isProduction}
/>
)}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<form onSubmit={onSubmit}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="name@example.com"
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<button
type="button"
onClick={() => {
const emailInput = document.getElementById('email') as HTMLInputElement
setForgotPasswordEmail(emailInput?.value || '')
setForgotPasswordOpen(true)
}}
className="text-xs text-primary hover:underline"
>
Forgot password?
</button>
</div>
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</div>
</form>
</div>
</CardContent>
<CardFooter>
<p className="text-sm text-gray-500 text-center w-full">
Don't have an account?{' '}
<Link
href={mounted && searchParams ? `/signup?${searchParams.toString()}` : '/signup'}
className="text-primary hover:underline"
>
Sign up
</Link>
</p>
</CardFooter>
</Card>
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-[32px] font-semibold tracking-tight text-white">Sign In</h1>
<p className="text-sm text-neutral-400">
Enter your email below to sign in to your account
</p>
</div>
{/* Forgot Password Dialog */}
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
</DialogHeader>
<RequestResetForm
email={forgotPasswordEmail}
onEmailChange={setForgotPasswordEmail}
onSubmit={handleForgotPassword}
isSubmitting={isSubmittingReset}
statusType={resetStatus.type}
statusMessage={resetStatus.message}
className="py-4"
<div className="flex flex-col gap-6">
<div className="bg-neutral-800/50 backdrop-blur-sm border border-neutral-700/40 rounded-xl p-6">
<form onSubmit={onSubmit} className="space-y-5">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">
Email
</Label>
<Input
id="email"
name="email"
placeholder="Enter your email"
required
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="bg-neutral-900 border-neutral-700 text-white"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-neutral-300">
Password
</Label>
<button
type="button"
onClick={() => setForgotPasswordOpen(true)}
className="text-xs text-neutral-400 hover:text-white transition font-medium"
>
Forgot password?
</button>
</div>
<div className="relative">
<Input
id="password"
name="password"
required
type={showPassword ? 'text' : 'password'}
autoCapitalize="none"
autoComplete="current-password"
autoCorrect="off"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-neutral-900 border-neutral-700 text-white pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white transition"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
</div>
<Button
type="submit"
className="w-full bg-[#701ffc] hover:bg-[#802FFF] h-11 font-medium text-base text-white shadow-lg shadow-[#701ffc]/20 transition-colors duration-200 flex items-center justify-center gap-2"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-neutral-700/50"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-neutral-800/50 px-2 text-neutral-400">or continue with</span>
</div>
</div>
<SocialLoginButtons
googleAvailable={googleAvailable}
githubAvailable={githubAvailable}
isProduction={isProduction}
callbackURL={callbackUrl}
/>
</div>
<div className="text-center text-sm">
<span className="text-neutral-400">Don't have an account? </span>
<Link
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
className="text-[#9D54FF] hover:text-[#a66fff] font-medium transition underline-offset-4 hover:underline"
>
Sign up
</Link>
</div>
</div>
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<DialogContent className="bg-neutral-800/90 border border-neutral-700/50 text-white backdrop-blur-sm">
<DialogHeader>
<DialogTitle className="text-xl font-semibold tracking-tight text-white">
Reset Password
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-neutral-300">
Enter your email address and we'll send you a link to reset your password.
</div>
<div className="space-y-2">
<Label htmlFor="reset-email" className="text-neutral-300">
Email
</Label>
<Input
id="reset-email"
value={forgotPasswordEmail}
onChange={(e) => setForgotPasswordEmail(e.target.value)}
placeholder="Enter your email"
required
type="email"
className="bg-neutral-900 border-neutral-700/80 text-white focus:border-[#802FFF]/70 focus:ring-[#802FFF]/20"
/>
</div>
{resetStatus.type && (
<div
className={`text-sm ${
resetStatus.type === 'success' ? 'text-[#4CAF50]' : 'text-red-500'
}`}
>
{resetStatus.message}
</div>
)}
<Button
type="button"
onClick={handleForgotPassword}
className="w-full bg-[#701ffc] hover:bg-[#802FFF] h-11 font-medium text-base text-white shadow-lg shadow-[#701ffc]/20 transition-colors duration-200"
disabled={isSubmittingReset}
>
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
</Button>
</div>
</DialogContent>
</Dialog>
</main>
</div>
)
}

View File

@@ -12,7 +12,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { createLogger } from '@/lib/logs/console-logger'
import { SetNewPasswordForm } from '../components/reset-password-form'
import { SetNewPasswordForm } from './reset-password-form'
const logger = createLogger('ResetPasswordPage')

View File

@@ -3,22 +3,13 @@
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Eye, EyeOff } from 'lucide-react'
import { Command, CornerDownLeft, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client } from '@/lib/auth-client'
import { useNotificationStore } from '@/stores/notifications/store'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
const PASSWORD_VALIDATIONS = {
minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' },
@@ -148,7 +139,9 @@ function SignupFormContent({
setPassword(newPassword)
// Silently validate but don't show errors
validatePassword(newPassword)
const errors = validatePassword(newPassword)
setPasswordErrors(errors)
setShowValidationError(false)
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
@@ -198,20 +191,16 @@ function SignupFormContent({
errorMessage = 'Please enter a valid email address.'
} else if (ctx.error.message?.includes('PASSWORD_TOO_SHORT')) {
errorMessage = 'Password must be at least 8 characters long.'
} else if (ctx.error.message?.includes('PASSWORD_TOO_LONG')) {
errorMessage = 'Password must be less than 128 characters.'
} else if (ctx.error.message?.includes('USER_ALREADY_EXISTS')) {
errorMessage = 'An account with this email already exists. Please sign in instead.'
} else if (ctx.error.message?.includes('MISSING_CREDENTIALS')) {
errorMessage = 'Please enter all required fields.'
} else if (ctx.error.message?.includes('EMAIL_PASSWORD_DISABLED')) {
errorMessage = 'Email and password signup is disabled.'
} else if (ctx.error.message?.includes('FAILED_TO_CREATE_USER')) {
errorMessage = 'Failed to create account. Please try again later.'
} else if (ctx.error.message?.includes('FAILED_TO_CREATE_SESSION')) {
errorMessage = 'Failed to create session. Please try again later.'
} else if (ctx.error.message?.includes('rate limit')) {
errorMessage = 'Too many signup attempts. Please try again later.'
} else if (ctx.error.message?.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (ctx.error.message?.includes('invalid name')) {
errorMessage = 'Please enter a valid name.'
} else if (ctx.error.message?.includes('rate limit')) {
errorMessage = 'Too many requests. Please wait a moment before trying again.'
}
addNotification('error', errorMessage, null)
@@ -224,126 +213,175 @@ function SignupFormContent({
return
}
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailValue)
// If this is an invitation flow, store that information for after verification
if (isInviteFlow && redirectUrl) {
sessionStorage.setItem('inviteRedirectUrl', redirectUrl)
sessionStorage.setItem('isInviteFlow', 'true')
// If we have a waitlist token, mark it as used
if (waitlistToken) {
try {
await fetch('/api/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: waitlistToken,
email: emailValue,
action: 'use',
}),
})
} catch (error) {
console.error('Error marking waitlist token as used:', error)
// Continue regardless - this is not critical
}
}
// If verification is required, go to verify page with proper redirect
// Handle invitation flow redirect
if (isInviteFlow && redirectUrl) {
router.push(
`/verify?fromSignup=true&redirectAfter=${encodeURIComponent(redirectUrl)}&invite_flow=true`
)
} else {
router.push(`/verify?fromSignup=true`)
router.push(redirectUrl)
return
}
} catch (err: any) {
console.error('Uncaught signup error:', err)
} finally {
// Send verification OTP in Prod
try {
await client.emailOtp.sendVerificationOtp({
email: emailValue,
type: 'email-verification',
})
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailValue)
localStorage.setItem('has_logged_in_before', 'true')
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
}
router.push('/verify')
} catch (error) {
console.error('Failed to send verification code:', error)
addNotification('error', 'Account created but failed to send verification code.', null)
router.push('/login')
}
} catch (error) {
console.error('Signup error:', error)
setIsLoading(false)
}
}
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
{/* Ensure NotificationList is always rendered */}
<NotificationList />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h1 className="text-2xl font-bold text-center mb-8">Sim Studio</h1>
<Card className="w-full">
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>Enter your details to get started</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6">
<SocialLoginButtons
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
callbackURL="/w"
isProduction={isProduction}
/>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<form onSubmit={onSubmit}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input id="name" name="name" type="text" placeholder="Alan Turing" required />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="name@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
placeholder="Enter your password"
value={password}
onChange={handlePasswordChange}
/>
<button
type="button"
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className="text-sm text-red-500 mt-1">
<p>Password must:</p>
<ul className="list-disc pl-5 mt-1">
{passwordErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
</div>
</form>
</div>
</CardContent>
<CardFooter>
<p className="text-sm text-gray-500 text-center w-full">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</Card>
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-[32px] font-semibold tracking-tight text-white">Create Account</h1>
<p className="text-sm text-neutral-400">Enter your details to create a new account</p>
</div>
</main>
<div className="flex flex-col gap-6">
<div className="bg-neutral-800/50 backdrop-blur-sm border border-neutral-700/40 rounded-xl p-6">
<form onSubmit={onSubmit} className="space-y-5">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-neutral-300">
Full Name
</Label>
<Input
id="name"
name="name"
placeholder="Enter your name"
required
type="text"
autoCapitalize="words"
autoComplete="name"
className="bg-neutral-900 border-neutral-700 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">
Email
</Label>
<Input
id="email"
name="email"
placeholder="Enter your email"
required
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-neutral-900 border-neutral-700 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-neutral-300">
Password
</Label>
<div className="relative">
<Input
id="password"
name="password"
required
type={showPassword ? 'text' : 'password'}
autoCapitalize="none"
autoComplete="new-password"
placeholder="Enter your password"
autoCorrect="off"
value={password}
onChange={handlePasswordChange}
className="bg-neutral-900 border-neutral-700 text-white pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white transition"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className="text-xs text-red-400 mt-1 space-y-1">
{passwordErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
<Button
type="submit"
className="w-full bg-[#701ffc] hover:bg-[#802FFF] h-11 font-medium text-base text-white shadow-lg shadow-[#701ffc]/20 transition-colors duration-200 flex items-center justify-center gap-2"
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create Account'}
</Button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-neutral-700/50"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-neutral-800/50 px-2 text-neutral-400">or continue with</span>
</div>
</div>
<SocialLoginButtons
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
callbackURL={redirectUrl || '/w'}
isProduction={isProduction}
/>
</div>
<div className="text-center text-sm">
<span className="text-neutral-400">Already have an account? </span>
<Link
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
className="text-[#9D54FF] hover:text-[#a66fff] font-medium transition underline-offset-4 hover:underline"
>
Sign in
</Link>
</div>
</div>
</div>
)
}
@@ -358,11 +396,7 @@ export default function SignupPage({
}) {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
}
fallback={<div className="h-screen flex items-center justify-center">Loading...</div>}
>
<SignupFormContent
githubAvailable={githubAvailable}

View File

@@ -8,15 +8,7 @@ export const dynamic = 'force-dynamic'
export default function VerifyPage() {
const baseUrl = getBaseUrl()
const hasResendKey = Boolean(env.RESEND_API_KEY && env.RESEND_API_KEY !== 'placeholder')
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h1 className="text-2xl font-bold text-center mb-8">Sim Studio</h1>
<VerifyContent hasResendKey={hasResendKey} baseUrl={baseUrl} isProduction={isProd} />
</div>
</main>
)
return <VerifyContent hasResendKey={hasResendKey} baseUrl={baseUrl} isProduction={isProd} />
}

View File

@@ -211,6 +211,17 @@ export function useVerification({
setOtp(value)
}
useEffect(() => {
if (!isProduction || !hasResendKey) {
setIsVerified(true)
const timeoutId = setTimeout(() => {
router.push('/w')
}, 1000)
return () => clearTimeout(timeoutId)
}
}, [isProduction, hasResendKey, router])
return {
otp,
email,

View File

@@ -2,14 +2,6 @@
import { Suspense, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import { cn } from '@/lib/utils'
import { useVerification } from './use-verification'
@@ -59,122 +51,127 @@ function VerificationForm({
}
return (
<>
<CardHeader>
<CardTitle>{isVerified ? 'Email Verified!' : 'Verify your email'}</CardTitle>
<CardDescription>
{isVerified ? (
'Your email has been verified. Redirecting to dashboard...'
) : hasResendKey ? (
<p>A verification code has been sent to {email || 'your email'}</p>
) : !isProduction ? (
<div className="space-y-1">
<p>Development mode: No Resend API key configured</p>
<p className="text-xs text-muted-foreground italic">
Check your console logs for the verification code
</p>
</div>
) : (
<p>Error: Invalid API key configuration</p>
)}
</CardDescription>
</CardHeader>
{/* Add debug output for error state */}
<div className="hidden">
Debug - isInvalidOtp: {String(isInvalidOtp)}, errorMessage: {errorMessage || 'none'}
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-[32px] font-semibold tracking-tight text-white">
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
</h1>
<p className="text-sm text-neutral-400">
{isVerified
? 'Your email has been verified. Redirecting to dashboard...'
: hasResendKey
? `A verification code has been sent to ${email || 'your email'}`
: !isProduction
? 'Development mode: Check your console logs for the verification code'
: 'Error: Invalid API key configuration'}
</p>
</div>
{!isVerified && (
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground mb-2">
Enter the 6-digit code to verify your account.
{hasResendKey ? " If you don't see it in your email, check your spam folder." : ''}
</p>
<div className="flex flex-col items-center space-y-2">
<div className="flex flex-col gap-6">
<div className="bg-neutral-800/50 backdrop-blur-sm border border-neutral-700/40 rounded-xl p-6">
<p className="text-sm text-neutral-400 mb-4">
Enter the 6-digit code to verify your account.
{hasResendKey ? " If you don't see it in your inbox, check your spam folder." : ''}
</p>
<div className="flex justify-center py-4">
<InputOTP
maxLength={6}
value={otp}
onChange={handleOtpChange}
disabled={isLoading}
className={cn(isInvalidOtp && 'border-red-500 focus-visible:ring-red-500')}
className={cn(
isInvalidOtp ? 'border-red-500 focus-visible:ring-red-500' : 'border-neutral-700'
)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot
index={0}
className="bg-neutral-900 border-neutral-700 text-white"
/>
<InputOTPSlot
index={1}
className="bg-neutral-900 border-neutral-700 text-white"
/>
<InputOTPSlot
index={2}
className="bg-neutral-900 border-neutral-700 text-white"
/>
<InputOTPSlot
index={3}
className="bg-neutral-900 border-neutral-700 text-white"
/>
<InputOTPSlot
index={4}
className="bg-neutral-900 border-neutral-700 text-white"
/>
<InputOTPSlot
index={5}
className="bg-neutral-900 border-neutral-700 text-white"
/>
</InputOTPGroup>
</InputOTP>
</div>
</div>
{/* Error message - moved above the button for better visibility */}
{errorMessage && (
<div className="mt-2 mb-2 text-center border border-red-200 rounded-md py-2 bg-red-50">
<p className="text-sm font-semibold text-red-600">{errorMessage}</p>
</div>
)}
<Button onClick={verifyCode} className="w-full" disabled={!isOtpComplete || isLoading}>
{isLoading ? 'Verifying...' : 'Verify Email'}
</Button>
</CardContent>
)}
{!isVerified && hasResendKey && (
<CardFooter className="flex justify-center">
<p className="text-sm text-muted-foreground">
Didn't receive a code?{' '}
{countdown > 0 ? (
<span className="text-muted-foreground">
Resend in <span className="font-medium text-primary">{countdown}s</span>
</span>
) : (
<button
className="text-primary hover:underline font-medium"
onClick={handleResend}
disabled={isLoading || isResendDisabled}
>
Resend
</button>
{/* Error message */}
{errorMessage && (
<div className="mt-2 mb-4 text-center border border-red-900/20 rounded-md py-2 bg-red-900/10">
<p className="text-sm font-medium text-red-400">{errorMessage}</p>
</div>
)}
</p>
</CardFooter>
<Button
onClick={verifyCode}
className="w-full bg-[#701ffc] hover:bg-[#802FFF] h-11 font-medium text-base text-white shadow-lg shadow-[#701ffc]/20 transition-colors duration-200"
disabled={!isOtpComplete || isLoading}
>
{isLoading ? 'Verifying...' : 'Verify Email'}
</Button>
{hasResendKey && (
<div className="mt-4 text-center">
<p className="text-sm text-neutral-400">
Didn't receive a code?{' '}
{countdown > 0 ? (
<span>
Resend in <span className="font-medium text-neutral-300">{countdown}s</span>
</span>
) : (
<button
className="text-[#9D54FF] hover:text-[#a66fff] font-medium transition underline-offset-4 hover:underline"
onClick={handleResend}
disabled={isLoading || isResendDisabled}
>
Resend
</button>
)}
</p>
</div>
)}
</div>
</div>
)}
</>
</div>
)
}
// Fallback component while the verification form is loading
function VerificationFormFallback() {
return (
<CardHeader>
<CardTitle>Loading verification...</CardTitle>
<CardDescription>Please wait while we load your verification details...</CardDescription>
</CardHeader>
<div className="text-center p-8">
<div className="animate-pulse">
<div className="h-8 bg-neutral-800 rounded w-48 mx-auto mb-4"></div>
<div className="h-4 bg-neutral-800 rounded w-64 mx-auto"></div>
</div>
</div>
)
}
export function VerifyContent({ hasResendKey, baseUrl, isProduction }: VerifyContentProps) {
return (
<Card className="w-full">
<Suspense fallback={<VerificationFormFallback />}>
<VerificationForm hasResendKey={hasResendKey} isProduction={isProduction} />
</Suspense>
{/* Login link for already verified users */}
<CardFooter className="flex justify-center pt-0">
<p className="text-sm text-muted-foreground">
Already have an account? Go to{' '}
<a href="/login" className="text-primary hover:underline font-medium">
Login
</a>
</p>
</CardFooter>
</Card>
<Suspense fallback={<VerificationFormFallback />}>
<VerificationForm hasResendKey={hasResendKey} isProduction={isProduction} />
</Suspense>
)
}

View File

@@ -4,164 +4,73 @@ import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Command, CornerDownLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useSession } from '@/lib/auth-client'
import { GridPattern } from '../grid-pattern'
import HeroWorkflowProvider from '../hero-workflow'
function Hero() {
const router = useRouter()
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [hasLoggedInBefore, setHasLoggedInBefore] = useState(false)
const [isTransitioning, setIsTransitioning] = useState(true)
const { data: session, isPending } = useSession()
const isAuthenticated = !isPending && !!session?.user
const [isTransitioning, setIsTransitioning] = useState(true)
const handleStartNowClick = () => {
// Ensure the cookie is set for middleware
const handleNavigate = () => {
if (typeof window !== 'undefined') {
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
}
// Check if user has an active session
if (isAuthenticated) {
router.push('/w')
} else {
// Check if user has logged in before
const hasLoggedInBefore =
localStorage.getItem('has_logged_in_before') === 'true' ||
document.cookie.includes('has_logged_in_before=true')
// If user has an active session, go directly to workflows
if (isAuthenticated) {
router.push('/w')
} else {
// User has logged in before but doesn't have an active session
router.push('/login')
}
}
useEffect(() => {
// Check if user has previously logged in
if (typeof window !== 'undefined') {
const loggedInBefore = localStorage.getItem('has_logged_in_before') === 'true'
setHasLoggedInBefore(loggedInBefore)
}
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
if (isAuthenticated) {
router.push('/w')
} else {
if (hasLoggedInBefore) {
// User has logged in before but doesn't have an active session
router.push('/login')
} else {
// User has never logged in before
router.push('/signup')
}
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [router, isAuthenticated])
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
handleNavigate()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isAuthenticated])
// Handle transition state
useEffect(() => {
const timer = setTimeout(() => {
setIsTransitioning(false)
}, 800) // Slightly longer than animation-delay to ensure smooth appearance
}, 300) // Reduced delay for faster button appearance
return () => clearTimeout(timer)
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setStatus('idle')
// Simple email validation
if (!email.includes('@') || email.trim().length < 5) {
setStatus('error')
return
}
try {
setIsSubmitting(true)
const response = await fetch('/api/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
})
// Always show success for valid emails, regardless of API response
setStatus('success')
setEmail('')
} catch (error) {
// Don't show error to user, just log it
console.error('Error submitting email:', error)
setStatus('success')
} finally {
setIsSubmitting(false)
}
}
const getButtonText = () => {
if (isSubmitting) return 'Joining...'
if (status === 'success') return 'Joined!'
if (status === 'error') return 'Try again'
return 'Join waitlist'
}
const getButtonStyle = () => {
switch (status) {
case 'success':
return 'bg-green-500 hover:bg-green-600 text-white'
case 'error':
return 'bg-red-500 hover:bg-red-600 text-white'
default:
return 'bg-[#701ffc] hover:bg-[#802FFF] text-white'
}
}
// Render the appropriate action UI based on auth status
const renderActionUI = () => {
// If we're still in the initial animation phase or auth is pending,
// return an empty div with height to maintain layout consistency
if (isTransitioning || isPending) {
return <div className="h-[56px] md:h-[64px]" />
}
if (isAuthenticated || hasLoggedInBefore) {
return (
<Button
variant={'secondary'}
onClick={handleStartNowClick}
className="bg-[#701ffc] font-geist-sans items-center px-7 py-6 text-lg text-neutral-100 font-[420] tracking-normal shadow-lg shadow-[#701ffc]/30 hover:bg-[#802FFF] animate-fade-in"
aria-label="Start using the platform"
>
<div className="text-[1.15rem]">Start now</div>
<div className="flex items-center gap-1 pl-2 opacity-80" aria-hidden="true">
<Command size={24} />
<CornerDownLeft />
</div>
</Button>
)
}
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-3 items-center w-full mx-auto mt-1 md:mt-2 px-4 sm:px-0 animate-fade-in"
<Button
variant={'secondary'}
onClick={handleNavigate}
className="bg-[#701ffc] font-geist-sans items-center px-7 py-6 text-lg text-neutral-100 font-[420] tracking-normal shadow-lg shadow-[#701ffc]/30 hover:bg-[#802FFF] animate-fade-in"
aria-label="Start using the platform"
>
<div className="flex w-full max-w-xs sm:max-w-sm md:max-w-md gap-2 sm:gap-3">
<Input
type="email"
placeholder="you@example.com"
className="flex-1 min-w-0 h-[48px] bg-[#121212]/60 border-[rgba(255,255,255,0.08)] focus:border-[rgba(255,255,255,0.15)] text-white placeholder:text-neutral-500 text-sm sm:text-base font-medium rounded-md shadow-inner"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
/>
<Button
type="submit"
className={`h-[47px] font-medium px-6 rounded-md shadow-lg ${getButtonStyle()} shadow-[#701ffc]/20`}
disabled={isSubmitting}
>
{getButtonText()}
</Button>
<div className="text-[1.15rem]">Start now</div>
<div className="flex items-center gap-1 pl-2 opacity-80" aria-hidden="true">
<Command size={24} />
<CornerDownLeft />
</div>
</form>
</Button>
)
}

View File

@@ -15,7 +15,7 @@ import {
import { signOut, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { RequestResetForm } from '@/app/(auth)/components/reset-password-form'
import { RequestResetForm } from '@/app/(auth)/reset-password/reset-password-form'
import { clearUserData } from '@/stores'
const logger = createLogger('Account')

View File

@@ -1,28 +1,13 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import clsx from 'clsx'
import {
ChevronLeft,
ChevronRight,
HelpCircle,
Home,
PanelRight,
PenLine,
ScrollText,
Send,
Settings,
Shapes,
Store,
Users,
} from 'lucide-react'
import { AgentIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { HelpCircle, ScrollText, Send, Settings } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { isProd } from '@/lib/environment'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -172,6 +157,9 @@ export function Sidebar() {
mode === 'hover' &&
((isHovered && !isAnyModalOpen && explicitMouseEnter) || workspaceDropdownOpen)
// Invite members is only shown in production
const shouldShowInviteMembers = isProd
return (
<aside
className={clsx(
@@ -288,17 +276,19 @@ export function Sidebar() {
<div className="flex-shrink-0 px-3 pb-3 pt-1">
<div className="flex flex-col space-y-[1px]">
{/* Invite members button */}
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() => setShowInviteMembers(true)}
className="flex items-center justify-center rounded-md text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer w-8 h-8 mx-auto"
>
<Send className="h-[18px] w-[18px]" />
</div>
</TooltipTrigger>
<TooltipContent side="right">Invite Members</TooltipContent>
</Tooltip>
{shouldShowInviteMembers && (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() => setShowInviteMembers(true)}
className="flex items-center justify-center rounded-md text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer w-8 h-8 mx-auto"
>
<Send className="h-[18px] w-[18px]" />
</div>
</TooltipTrigger>
<TooltipContent side="right">Invite Members</TooltipContent>
</Tooltip>
)}
{/* Help button */}
<Tooltip>
@@ -325,15 +315,17 @@ export function Sidebar() {
) : (
<>
{/* Invite members bar */}
<div className="flex-shrink-0 px-3 pt-1">
<div
onClick={() => setShowInviteMembers(true)}
className="flex items-center rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer"
>
<Send className="h-[18px] w-[18px]" />
<span className="ml-2">Invite members</span>
{shouldShowInviteMembers && (
<div className="flex-shrink-0 px-3 pt-1">
<div
onClick={() => setShowInviteMembers(true)}
className="flex items-center rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer"
>
<Send className="h-[18px] w-[18px]" />
<span className="ml-2">Invite members</span>
</div>
</div>
</div>
)}
{/* Bottom buttons container */}
<div className="flex-shrink-0 px-3 pb-3 pt-1">

View File

@@ -21,6 +21,7 @@ import { env } from './env'
const logger = createLogger('Auth')
const isProd = env.NODE_ENV === 'production'
const isDevOrDocker = env.NODE_ENV === 'development' || env.DOCKER_BUILD
// Only initialize Stripe if the key is provided
// This allows local development without a Stripe account
@@ -164,6 +165,10 @@ export const auth = betterAuth({
otp: string
type: 'sign-in' | 'email-verification' | 'forget-password'
}) => {
if (isDevOrDocker) {
logger.info('Skipping email verification in dev/docker')
return
}
try {
if (!data.email) {
throw new Error('Email is required')
@@ -200,9 +205,9 @@ export const auth = betterAuth({
throw error
}
},
sendVerificationOnSignUp: true,
otpLength: 6, // Explicitly set the OTP length
expiresIn: 15 * 60, // 15 minutes in seconds
sendVerificationOnSignUp: !isDevOrDocker,
otpLength: 6,
expiresIn: 15 * 60,
}),
genericOAuth({
config: [

View File

@@ -55,55 +55,45 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/w/1', request.url))
}
// Handle protected routes that require authentication
if (url.pathname.startsWith('/w/') || url.pathname === '/w') {
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
// Allow access to invitation links
if (request.nextUrl.pathname.startsWith('/invite/')) {
// If this is an invitation and the user is not logged in,
// and this isn't a login/signup-related request, redirect to login
if (
!hasActiveSession &&
!request.nextUrl.pathname.endsWith('/login') &&
!request.nextUrl.pathname.endsWith('/signup') &&
!request.nextUrl.search.includes('callbackUrl')
) {
// Prepare invitation URL for callback after login
const token = request.nextUrl.searchParams.get('token')
const inviteId = request.nextUrl.pathname.split('/').pop()
// Build the callback URL - retain the invitation path with token
const callbackParam = encodeURIComponent(
`/invite/${inviteId}${token ? `?token=${token}` : ''}`
)
// Redirect to login with callback
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url)
)
}
return NextResponse.next()
}
// Allow access to workspace invitation API endpoint
if (request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) {
// If the endpoint is for accepting an invitation and user is not logged in
if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) {
const token = request.nextUrl.searchParams.get('token')
if (token) {
// Redirect to the client-side invite page instead of directly to login
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url))
}
}
return NextResponse.next()
}
// Handle protected routes that require authentication
if (url.pathname.startsWith('/w/') || url.pathname === '/w') {
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
// If self-hosted skip waitlist
if (env.DOCKER_BUILD) {
return NextResponse.next()
@@ -114,76 +104,8 @@ export async function middleware(request: NextRequest) {
return NextResponse.next()
}
// If user has an active session, allow them to access any route
if (hasActiveSession) {
return NextResponse.next()
}
// Handle waitlist protection for login and signup in production
if (
url.pathname === '/login' ||
url.pathname === '/signup' ||
url.pathname === '/auth/login' ||
url.pathname === '/auth/signup'
) {
// If this is the login page and user has logged in before, allow access
if (
hasPreviouslyLoggedIn &&
(request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/auth/login')
) {
return NextResponse.next()
}
// Check for invite_flow parameter indicating the user is in an invitation flow
const isInviteFlow = url.searchParams.get('invite_flow') === 'true'
// Check for a waitlist token in the URL
const waitlistToken = url.searchParams.get('token')
// If there's a redirect to the invite page or we're in an invite flow, bypass waitlist check
const redirectParam = request.nextUrl.searchParams.get('redirect')
if ((redirectParam && redirectParam.startsWith('/invite/')) || isInviteFlow) {
return NextResponse.next()
}
// 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 (url.pathname === '/signup') {
return NextResponse.redirect(new URL('/', request.url))
}
} catch (error) {
logger.error('Token validation error:', error)
// In case of error, redirect signup attempts to home
if (url.pathname === '/signup') {
return NextResponse.redirect(new URL('/', request.url))
}
}
} else {
// If no token for signup, redirect to home
if (url.pathname === '/signup') {
return NextResponse.redirect(new URL('/', request.url))
}
}
}
const userAgent = request.headers.get('user-agent') || ''
const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent))
if (isSuspicious) {
logger.warn('Blocked suspicious request', {
userAgent,
@@ -192,8 +114,6 @@ export async function middleware(request: NextRequest) {
method: request.method,
pattern: SUSPICIOUS_UA_PATTERNS.find((pattern) => pattern.test(userAgent))?.toString(),
})
// Return 403 with security headers
return new NextResponse(null, {
status: 403,
statusText: 'Forbidden',
@@ -210,9 +130,7 @@ export async function middleware(request: NextRequest) {
}
const response = NextResponse.next()
response.headers.set('Vary', 'User-Agent')
return response
}

View File

@@ -0,0 +1,36 @@
# Sim Studio CLI
Sim Studio CLI allows you to run [Sim Studio](https://simstudio.ai) using Docker with a single command.
## Installation
To install the Sim Studio CLI globally, use:
```bash
npm install -g simstudio
```
## Usage
To start Sim Studio, simply run:
```bash
simstudio
```
### Options
- `-p, --port <port>`: Specify the port to run Sim Studio on (default: 3000).
- `--no-pull`: Skip pulling the latest Docker images.
## Requirements
- Docker must be installed and running on your machine.
## Contributing
Contributions are welcome! Please open an issue or submit a pull request.
## License
This project is licensed under the Apache-2.0 License.

View File

@@ -1,6 +1,6 @@
{
"name": "simstudio",
"version": "0.1.16",
"version": "0.1.17",
"description": "Sim Studio CLI - Run Sim Studio with a single command",
"main": "dist/index.js",
"bin": {