mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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>
|
||||
|
||||
41
apps/sim/app/(auth)/layout.tsx
Normal file
41
apps/sim/app/(auth)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
36
packages/simstudio/README.md
Normal file
36
packages/simstudio/README.md
Normal 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.
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user