mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 06:58:07 -05:00
feat(signup): added back to login functionality to OTP page (#1365)
* update infra and remove railway
* feat(signup): added back to login functionalityfrom OTP page
* remove placeholders from docker commands, simplified login flow
* Revert "update infra and remove railway"
This reverts commit abfa2f8d51.
This commit is contained in:
@@ -4,19 +4,9 @@ import { env } from '@/lib/env'
|
||||
import { isProd } from '@/lib/environment'
|
||||
|
||||
export async function getOAuthProviderStatus() {
|
||||
const githubAvailable = !!(
|
||||
env.GITHUB_CLIENT_ID &&
|
||||
env.GITHUB_CLIENT_SECRET &&
|
||||
env.GITHUB_CLIENT_ID !== 'placeholder' &&
|
||||
env.GITHUB_CLIENT_SECRET !== 'placeholder'
|
||||
)
|
||||
const githubAvailable = !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET)
|
||||
|
||||
const googleAvailable = !!(
|
||||
env.GOOGLE_CLIENT_ID &&
|
||||
env.GOOGLE_CLIENT_SECRET &&
|
||||
env.GOOGLE_CLIENT_ID !== 'placeholder' &&
|
||||
env.GOOGLE_CLIENT_SECRET !== 'placeholder'
|
||||
)
|
||||
const googleAvailable = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET)
|
||||
|
||||
return { githubAvailable, googleAvailable, isProduction: isProd }
|
||||
}
|
||||
|
||||
@@ -37,12 +37,6 @@ export function SocialLoginButtons({
|
||||
setIsGithubLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'github', callbackURL })
|
||||
|
||||
// Mark that the user has previously logged in
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('has_logged_in_before', 'true')
|
||||
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign in with GitHub'
|
||||
|
||||
@@ -66,13 +60,6 @@ export function SocialLoginButtons({
|
||||
setIsGoogleLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'google', callbackURL })
|
||||
|
||||
// Mark that the user has previously logged in
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('has_logged_in_before', 'true')
|
||||
// Also set a cookie to enable middleware to check login status
|
||||
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign in with Google'
|
||||
|
||||
|
||||
@@ -74,12 +74,12 @@ const validatePassword = (passwordValue: string): string[] => {
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.required.message)
|
||||
return errors // Return early for required field
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.notEmpty.message)
|
||||
return errors // Return early for empty field
|
||||
return errors
|
||||
}
|
||||
|
||||
return errors
|
||||
@@ -104,11 +104,9 @@ export default function LoginPage({
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
// Initialize state for URL parameters
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
|
||||
// Forgot password states
|
||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
||||
@@ -117,25 +115,20 @@ export default function LoginPage({
|
||||
message: string
|
||||
}>({ type: null, message: '' })
|
||||
|
||||
// Email validation state
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
|
||||
// Extract URL parameters after component mounts to avoid SSR issues
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
// Only access search params on the client side
|
||||
if (searchParams) {
|
||||
const callback = searchParams.get('callbackUrl')
|
||||
if (callback) {
|
||||
// Validate the callbackUrl before setting it
|
||||
if (validateCallbackUrl(callback)) {
|
||||
setCallbackUrl(callback)
|
||||
} else {
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: callback })
|
||||
// Keep the default safe value ('/workspace')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,12 +136,10 @@ export default function LoginPage({
|
||||
setIsInviteFlow(inviteFlow)
|
||||
}
|
||||
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
@@ -158,7 +149,6 @@ export default function LoginPage({
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
@@ -189,7 +179,6 @@ export default function LoginPage({
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateEmailField(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
@@ -199,7 +188,6 @@ export default function LoginPage({
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validatePassword(newPassword)
|
||||
setPasswordErrors(errors)
|
||||
setShowValidationError(false)
|
||||
@@ -210,26 +198,23 @@ export default function LoginPage({
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const email = formData.get('email') as string
|
||||
const emailRaw = formData.get('email') as string
|
||||
const email = emailRaw.trim().toLowerCase()
|
||||
|
||||
// Validate email on submit
|
||||
const emailValidationErrors = validateEmailField(email)
|
||||
setEmailErrors(emailValidationErrors)
|
||||
setShowEmailValidationError(emailValidationErrors.length > 0)
|
||||
|
||||
// Validate password on submit
|
||||
const passwordValidationErrors = validatePassword(password)
|
||||
setPasswordErrors(passwordValidationErrors)
|
||||
setShowValidationError(passwordValidationErrors.length > 0)
|
||||
|
||||
// If there are validation errors, stop submission
|
||||
if (emailValidationErrors.length > 0 || passwordValidationErrors.length > 0) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Final validation before submission
|
||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
||||
|
||||
const result = await client.signIn.email(
|
||||
@@ -291,33 +276,13 @@ export default function LoginPage({
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark that the user has previously logged in
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('has_logged_in_before', 'true')
|
||||
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handle only the special verification case that requires a redirect
|
||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
try {
|
||||
await client.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
}
|
||||
|
||||
router.push('/verify')
|
||||
return
|
||||
} catch (_verifyErr) {
|
||||
setPasswordErrors(['Failed to send verification code. Please try again later.'])
|
||||
setShowValidationError(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
}
|
||||
router.push('/verify')
|
||||
return
|
||||
}
|
||||
|
||||
console.error('Uncaught login error:', err)
|
||||
|
||||
@@ -24,7 +24,6 @@ function ResetPasswordContent() {
|
||||
text: '',
|
||||
})
|
||||
|
||||
// Validate token presence
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatusMessage({
|
||||
@@ -60,7 +59,6 @@ function ResetPasswordContent() {
|
||||
text: 'Password reset successful! Redirecting to login...',
|
||||
})
|
||||
|
||||
// Redirect to login page after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login?resetSuccess=true')
|
||||
}, 1500)
|
||||
|
||||
@@ -30,12 +30,10 @@ export function RequestResetForm({
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
@@ -45,7 +43,6 @@ export function RequestResetForm({
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
@@ -132,12 +129,10 @@ export function SetNewPasswordForm({
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
@@ -147,7 +142,6 @@ export function SetNewPasswordForm({
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
@@ -164,7 +158,6 @@ export function SetNewPasswordForm({
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Simple validation
|
||||
if (password.length < 8) {
|
||||
setValidationMessage('Password must be at least 8 characters long')
|
||||
return
|
||||
|
||||
@@ -2,7 +2,6 @@ import { env, isTruthy } from '@/lib/env'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import SignupForm from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
// Force dynamic rendering to avoid prerender errors with search params
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SignupPage() {
|
||||
|
||||
@@ -95,7 +95,6 @@ function SignupFormContent({
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
// Name validation state
|
||||
const [name, setName] = useState('')
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
const [showNameValidationError, setShowNameValidationError] = useState(false)
|
||||
@@ -107,29 +106,24 @@ function SignupFormContent({
|
||||
setEmail(emailParam)
|
||||
}
|
||||
|
||||
// Handle redirection for invitation flow
|
||||
const redirectParam = searchParams.get('redirect')
|
||||
if (redirectParam) {
|
||||
setRedirectUrl(redirectParam)
|
||||
|
||||
// Check if this is part of an invitation flow
|
||||
if (redirectParam.startsWith('/invite/')) {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicitly check for invite_flow parameter
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
@@ -139,7 +133,6 @@ function SignupFormContent({
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
@@ -153,7 +146,6 @@ function SignupFormContent({
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Validate password and return array of error messages
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
@@ -180,18 +172,17 @@ function SignupFormContent({
|
||||
return errors
|
||||
}
|
||||
|
||||
// Validate name and return array of error messages
|
||||
const validateName = (nameValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!NAME_VALIDATIONS.required.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.required.message)
|
||||
return errors // Return early for required field
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.notEmpty.message)
|
||||
return errors // Return early for empty field
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) {
|
||||
@@ -209,7 +200,6 @@ function SignupFormContent({
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
|
||||
// Silently validate but don't show errors
|
||||
const errors = validatePassword(newPassword)
|
||||
setPasswordErrors(errors)
|
||||
setShowValidationError(false)
|
||||
@@ -228,12 +218,10 @@ function SignupFormContent({
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateEmailField(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
|
||||
// Clear any previous server-side email errors when the user starts typing
|
||||
if (emailError) {
|
||||
setEmailError('')
|
||||
}
|
||||
@@ -244,7 +232,8 @@ function SignupFormContent({
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailValue = formData.get('email') as string
|
||||
const emailValueRaw = formData.get('email') as string
|
||||
const emailValue = emailValueRaw.trim().toLowerCase()
|
||||
const passwordValue = formData.get('password') as string
|
||||
const nameValue = formData.get('name') as string
|
||||
|
||||
@@ -348,7 +337,6 @@ function SignupFormContent({
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh session to get the new user data immediately after signup
|
||||
try {
|
||||
await refetchSession()
|
||||
logger.info('Session refreshed after successful signup')
|
||||
@@ -356,34 +344,23 @@ function SignupFormContent({
|
||||
logger.error('Failed to refresh session after signup:', sessionError)
|
||||
}
|
||||
|
||||
// For new signups, always require verification
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', emailValue)
|
||||
localStorage.setItem('has_logged_in_before', 'true')
|
||||
|
||||
// Set cookie flag for middleware check
|
||||
document.cookie = 'requiresEmailVerification=true; path=/; max-age=900; SameSite=Lax' // 15 min expiry
|
||||
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax'
|
||||
|
||||
// Store invitation flow state if applicable
|
||||
if (isInviteFlow && redirectUrl) {
|
||||
sessionStorage.setItem('inviteRedirectUrl', redirectUrl)
|
||||
sessionStorage.setItem('isInviteFlow', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
// Send verification OTP manually
|
||||
try {
|
||||
await client.emailOtp.sendVerificationOtp({
|
||||
email: emailValue,
|
||||
type: 'email-verification',
|
||||
type: 'sign-in',
|
||||
})
|
||||
} catch (otpError) {
|
||||
logger.error('Failed to send OTP:', otpError)
|
||||
// Continue anyway - user can use resend button
|
||||
} catch (otpErr) {
|
||||
logger.warn('Failed to send sign-in OTP after signup; user can press Resend', otpErr)
|
||||
}
|
||||
|
||||
// Always redirect to verification for new signups
|
||||
router.push('/verify?fromSignup=true')
|
||||
} catch (error) {
|
||||
logger.error('Signup error:', error)
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
|
||||
|
||||
// Force dynamic rendering to avoid prerender errors with search params
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function VerifyPage() {
|
||||
const baseUrl = getBaseUrl()
|
||||
const hasResendKey = Boolean(env.RESEND_API_KEY && env.RESEND_API_KEY !== 'placeholder')
|
||||
const hasResendKey = Boolean(env.RESEND_API_KEY)
|
||||
|
||||
return <VerifyContent hasResendKey={hasResendKey} baseUrl={baseUrl} isProduction={isProd} />
|
||||
return <VerifyContent hasResendKey={hasResendKey} isProduction={isProd} />
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { client, useSession } from '@/lib/auth-client'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useVerification')
|
||||
@@ -47,61 +46,39 @@ export function useVerification({
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Get stored email
|
||||
const storedEmail = sessionStorage.getItem('verificationEmail')
|
||||
if (storedEmail) {
|
||||
setEmail(storedEmail)
|
||||
}
|
||||
|
||||
// Check for redirect information
|
||||
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
|
||||
if (storedRedirectUrl) {
|
||||
setRedirectUrl(storedRedirectUrl)
|
||||
}
|
||||
|
||||
// Check if this is an invite flow
|
||||
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
|
||||
if (storedIsInviteFlow === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Also check URL parameters for redirect information
|
||||
const redirectParam = searchParams.get('redirectAfter')
|
||||
if (redirectParam) {
|
||||
setRedirectUrl(redirectParam)
|
||||
}
|
||||
|
||||
// Check for invite_flow parameter
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Send initial OTP code if this is the first load
|
||||
useEffect(() => {
|
||||
if (email && !isSendingInitialOtp && hasResendKey) {
|
||||
setIsSendingInitialOtp(true)
|
||||
|
||||
// Only send verification OTP if we're coming from login page
|
||||
// Skip this if coming from signup since the OTP is already sent
|
||||
if (!searchParams.get('fromSignup')) {
|
||||
client.emailOtp
|
||||
.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
})
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to send initial verification code:', error)
|
||||
setErrorMessage('Failed to send verification code. Please use the resend button.')
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [email, isSendingInitialOtp, searchParams, hasResendKey])
|
||||
}, [email, isSendingInitialOtp, hasResendKey])
|
||||
|
||||
// Enable the verify button when all 6 digits are entered
|
||||
const isOtpComplete = otp.length === 6
|
||||
|
||||
async function verifyCode() {
|
||||
@@ -112,25 +89,24 @@ export function useVerification({
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
// Call the verification API with the OTP code
|
||||
const response = await client.emailOtp.verifyEmail({
|
||||
email,
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
const response = await client.signIn.emailOtp({
|
||||
email: normalizedEmail,
|
||||
otp,
|
||||
})
|
||||
|
||||
// Check if verification was successful
|
||||
if (response && !response.error) {
|
||||
setIsVerified(true)
|
||||
|
||||
// Clear verification requirements and session storage
|
||||
try {
|
||||
await refetchSession()
|
||||
} catch (e) {
|
||||
logger.warn('Failed to refetch session after verification', e)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
|
||||
// Clear the verification requirement flag
|
||||
document.cookie =
|
||||
'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
|
||||
// Also clear invite-related items
|
||||
if (isInviteFlow) {
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
@@ -139,24 +115,20 @@ export function useVerification({
|
||||
|
||||
setTimeout(() => {
|
||||
if (isInviteFlow && redirectUrl) {
|
||||
// For invitation flow, redirect to the invitation page
|
||||
window.location.href = redirectUrl
|
||||
} else {
|
||||
// Default redirect to dashboard
|
||||
window.location.href = '/workspace'
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
logger.info('Setting invalid OTP state - API error response')
|
||||
const message = 'Invalid verification code. Please check and try again.'
|
||||
// Set both state variables to ensure the error shows
|
||||
setIsInvalidOtp(true)
|
||||
setErrorMessage(message)
|
||||
logger.info('Error state after API error:', {
|
||||
isInvalidOtp: true,
|
||||
errorMessage: message,
|
||||
})
|
||||
// Clear the OTP input on invalid code
|
||||
setOtp('')
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -171,7 +143,6 @@ export function useVerification({
|
||||
message = 'Too many failed attempts. Please request a new code.'
|
||||
}
|
||||
|
||||
// Set both state variables to ensure the error shows
|
||||
setIsInvalidOtp(true)
|
||||
setErrorMessage(message)
|
||||
logger.info('Error state after caught error:', {
|
||||
@@ -179,7 +150,6 @@ export function useVerification({
|
||||
errorMessage: message,
|
||||
})
|
||||
|
||||
// Clear the OTP input on error
|
||||
setOtp('')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -192,10 +162,11 @@ export function useVerification({
|
||||
setIsLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
client.emailOtp
|
||||
.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
email: normalizedEmail,
|
||||
type: 'sign-in',
|
||||
})
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
@@ -207,7 +178,6 @@ export function useVerification({
|
||||
}
|
||||
|
||||
function handleOtpChange(value: string) {
|
||||
// Only clear error when user is actively typing a new code
|
||||
if (value.length === 6) {
|
||||
setIsInvalidOtp(false)
|
||||
setErrorMessage('')
|
||||
@@ -215,12 +185,11 @@ export function useVerification({
|
||||
setOtp(value)
|
||||
}
|
||||
|
||||
// Auto-submit when OTP is complete
|
||||
useEffect(() => {
|
||||
if (otp.length === 6 && email && !isLoading && !isVerified) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
verifyCode()
|
||||
}, 300) // Small delay to ensure UI is ready
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
@@ -229,17 +198,8 @@ export function useVerification({
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!isProduction || !hasResendKey) {
|
||||
const storedEmail = sessionStorage.getItem('verificationEmail')
|
||||
}
|
||||
|
||||
const isDevOrDocker = !isProduction || isTruthy(env.DOCKER_BUILD)
|
||||
|
||||
if (isDevOrDocker || !hasResendKey) {
|
||||
setIsVerified(true)
|
||||
|
||||
document.cookie =
|
||||
'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
window.location.href = '/workspace'
|
||||
}, 1000)
|
||||
|
||||
@@ -11,7 +11,6 @@ import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
interface VerifyContentProps {
|
||||
hasResendKey: boolean
|
||||
baseUrl: string
|
||||
isProduction: boolean
|
||||
}
|
||||
|
||||
@@ -56,30 +55,13 @@ function VerificationForm({
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
const handleCancelVerification = () => {
|
||||
// Clear verification data
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
|
||||
// Clear the verification requirement cookie
|
||||
document.cookie = 'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
}
|
||||
|
||||
// Redirect to login
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
// Check if CSS variable has been customized
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
// Check if the CSS variable exists and is different from the default
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
@@ -89,7 +71,6 @@ function VerificationForm({
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
// Also check on window resize or theme changes
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
@@ -232,21 +213,27 @@ function VerificationForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className='text-center font-light text-[14px]'>
|
||||
<div className='text-center font-light text-[14px]'>
|
||||
<button
|
||||
onClick={handleCancelVerification}
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
}
|
||||
router.push('/signup')
|
||||
}}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Back to login
|
||||
Back to signup
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback component while the verification form is loading
|
||||
function VerificationFormFallback() {
|
||||
return (
|
||||
<div className='text-center'>
|
||||
@@ -258,7 +245,7 @@ function VerificationFormFallback() {
|
||||
)
|
||||
}
|
||||
|
||||
export function VerifyContent({ hasResendKey, baseUrl, isProduction }: VerifyContentProps) {
|
||||
export function VerifyContent({ hasResendKey, isProduction }: VerifyContentProps) {
|
||||
return (
|
||||
<Suspense fallback={<VerificationFormFallback />}>
|
||||
<VerificationForm hasResendKey={hasResendKey} isProduction={isProduction} />
|
||||
|
||||
@@ -147,7 +147,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
requireEmailVerification: isProd,
|
||||
sendVerificationOnSignUp: false,
|
||||
throwOnMissingCredentials: true,
|
||||
throwOnInvalidCredentials: true,
|
||||
@@ -174,7 +174,6 @@ export const auth = betterAuth({
|
||||
if (ctx.path.startsWith('/sign-up') && isTruthy(env.DISABLE_REGISTRATION))
|
||||
throw new Error('Registration is disabled, please contact your admin.')
|
||||
|
||||
// Check email and domain whitelist for sign-in and sign-up
|
||||
if (
|
||||
(ctx.path.startsWith('/sign-in') || ctx.path.startsWith('/sign-up')) &&
|
||||
(env.ALLOWED_LOGIN_EMAILS || env.ALLOWED_LOGIN_DOMAINS)
|
||||
@@ -184,7 +183,6 @@ export const auth = betterAuth({
|
||||
if (requestEmail) {
|
||||
let isAllowed = false
|
||||
|
||||
// Check specific email whitelist
|
||||
if (env.ALLOWED_LOGIN_EMAILS) {
|
||||
const allowedEmails = env.ALLOWED_LOGIN_EMAILS.split(',').map((email) =>
|
||||
email.trim().toLowerCase()
|
||||
@@ -192,7 +190,6 @@ export const auth = betterAuth({
|
||||
isAllowed = allowedEmails.includes(requestEmail)
|
||||
}
|
||||
|
||||
// Check domain whitelist if not already allowed
|
||||
if (!isAllowed && env.ALLOWED_LOGIN_DOMAINS) {
|
||||
const allowedDomains = env.ALLOWED_LOGIN_DOMAINS.split(',').map((domain) =>
|
||||
domain.trim().toLowerCase()
|
||||
@@ -234,7 +231,6 @@ export const auth = betterAuth({
|
||||
throw new Error('Email is required')
|
||||
}
|
||||
|
||||
// Validate email before sending OTP
|
||||
const validation = quickValidateEmail(data.email)
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Email validation failed', {
|
||||
@@ -250,7 +246,6 @@ export const auth = betterAuth({
|
||||
|
||||
const html = await renderOTPEmail(data.otp, data.email, data.type)
|
||||
|
||||
// Send email via consolidated mailer (supports Resend, Azure, or logging fallback)
|
||||
const result = await sendEmail({
|
||||
to: data.email,
|
||||
subject: getEmailSubject(data.type),
|
||||
@@ -259,7 +254,6 @@ export const auth = betterAuth({
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
// If no email service is configured, log verification code for development
|
||||
if (!result.success && result.message.includes('no email service configured')) {
|
||||
logger.info('🔑 VERIFICATION CODE FOR LOGIN/SIGNUP', {
|
||||
email: data.email,
|
||||
@@ -300,7 +294,6 @@ export const auth = betterAuth({
|
||||
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/github-repo`,
|
||||
getUserInfo: async (tokens) => {
|
||||
try {
|
||||
// Fetch user profile
|
||||
const profileResponse = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
@@ -318,7 +311,6 @@ export const auth = betterAuth({
|
||||
|
||||
const profile = await profileResponse.json()
|
||||
|
||||
// If email is null, fetch emails separately
|
||||
if (!profile.email) {
|
||||
const emailsResponse = await fetch('https://api.github.com/user/emails', {
|
||||
headers: {
|
||||
@@ -330,7 +322,6 @@ export const auth = betterAuth({
|
||||
if (emailsResponse.ok) {
|
||||
const emails = await emailsResponse.json()
|
||||
|
||||
// Find primary email or use the first one
|
||||
const primaryEmail =
|
||||
emails.find(
|
||||
(email: { primary: boolean; email: string; verified: boolean }) =>
|
||||
@@ -366,7 +357,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
// Google providers for different purposes
|
||||
// Google providers
|
||||
{
|
||||
providerId: 'google-email',
|
||||
clientId: env.GOOGLE_CLIENT_ID as string,
|
||||
@@ -378,7 +369,6 @@ export const auth = betterAuth({
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/gmail.send',
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
// 'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
],
|
||||
prompt: 'consent',
|
||||
@@ -598,11 +588,9 @@ export const auth = betterAuth({
|
||||
try {
|
||||
logger.info('Creating Wealthbox user profile from token data')
|
||||
|
||||
// Generate a unique identifier since we can't fetch user info
|
||||
const uniqueId = `wealthbox-${Date.now()}`
|
||||
const now = new Date()
|
||||
|
||||
// Create a synthetic user profile
|
||||
return {
|
||||
id: uniqueId,
|
||||
name: 'Wealthbox User',
|
||||
@@ -625,8 +613,6 @@ export const auth = betterAuth({
|
||||
clientSecret: env.SUPABASE_CLIENT_SECRET as string,
|
||||
authorizationUrl: 'https://api.supabase.com/v1/oauth/authorize',
|
||||
tokenUrl: 'https://api.supabase.com/v1/oauth/token',
|
||||
// Supabase doesn't have a standard userInfo endpoint that works with our flow,
|
||||
// so we use a dummy URL and rely on our custom getUserInfo implementation
|
||||
userInfoUrl: 'https://dummy-not-used.supabase.co',
|
||||
scopes: ['database.read', 'database.write', 'projects.read'],
|
||||
responseType: 'code',
|
||||
@@ -636,11 +622,9 @@ export const auth = betterAuth({
|
||||
try {
|
||||
logger.info('Creating Supabase user profile from token data')
|
||||
|
||||
// Extract user identifier from tokens if possible
|
||||
let userId = 'supabase-user'
|
||||
if (tokens.idToken) {
|
||||
try {
|
||||
// Try to decode the JWT to get user information
|
||||
const decodedToken = JSON.parse(
|
||||
Buffer.from(tokens.idToken.split('.')[1], 'base64').toString()
|
||||
)
|
||||
@@ -654,12 +638,9 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique enough identifier
|
||||
const uniqueId = `${userId}-${Date.now()}`
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Create a synthetic user profile since we can't fetch one
|
||||
return {
|
||||
id: uniqueId,
|
||||
name: 'Supabase User',
|
||||
@@ -721,7 +702,7 @@ export const auth = betterAuth({
|
||||
return {
|
||||
id: profile.data.id,
|
||||
name: profile.data.name || 'X User',
|
||||
email: `${profile.data.username}@x.com`, // Create synthetic email with username
|
||||
email: `${profile.data.username}@x.com`,
|
||||
image: profile.data.profile_image_url,
|
||||
emailVerified: profile.data.verified || false,
|
||||
createdAt: now,
|
||||
@@ -774,7 +755,7 @@ export const auth = betterAuth({
|
||||
name: profile.name || profile.display_name || 'Confluence User',
|
||||
email: profile.email || `${profile.account_id}@atlassian.com`,
|
||||
image: profile.picture || undefined,
|
||||
emailVerified: true, // Assume verified since it's an Atlassian account
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -895,7 +876,7 @@ export const auth = betterAuth({
|
||||
name: profile.name || profile.display_name || 'Jira User',
|
||||
email: profile.email || `${profile.account_id}@atlassian.com`,
|
||||
image: profile.picture || undefined,
|
||||
emailVerified: true, // Assume verified since it's an Atlassian account
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -933,7 +914,7 @@ export const auth = betterAuth({
|
||||
userInfoUrl: 'https://api.notion.com/v1/users/me',
|
||||
scopes: ['workspace.content', 'workspace.name', 'page.read', 'page.write'],
|
||||
responseType: 'code',
|
||||
pkce: false, // Notion doesn't support PKCE
|
||||
pkce: false,
|
||||
accessType: 'offline',
|
||||
authentication: 'basic',
|
||||
prompt: 'consent',
|
||||
@@ -943,7 +924,7 @@ export const auth = betterAuth({
|
||||
const response = await fetch('https://api.notion.com/v1/users/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
'Notion-Version': '2022-06-28', // Specify the Notion API version
|
||||
'Notion-Version': '2022-06-28',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1011,7 +992,7 @@ export const auth = betterAuth({
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name || 'Reddit User',
|
||||
email: `${data.name}@reddit.user`, // Reddit doesn't provide email in identity scope
|
||||
email: `${data.name}@reddit.user`,
|
||||
image: data.icon_img || undefined,
|
||||
emailVerified: false,
|
||||
createdAt: now,
|
||||
@@ -1128,7 +1109,6 @@ export const auth = betterAuth({
|
||||
let userId = 'slack-bot'
|
||||
if (tokens.idToken) {
|
||||
try {
|
||||
// Try to decode the JWT to get user information
|
||||
const decodedToken = JSON.parse(
|
||||
Buffer.from(tokens.idToken.split('.')[1], 'base64').toString()
|
||||
)
|
||||
@@ -1140,12 +1120,9 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique enough identifier
|
||||
const uniqueId = `${userId}-${Date.now()}`
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Create a synthetic user profile since we can't fetch one
|
||||
return {
|
||||
id: uniqueId,
|
||||
name: 'Slack Bot',
|
||||
@@ -1230,7 +1207,6 @@ export const auth = betterAuth({
|
||||
status: subscription.status,
|
||||
})
|
||||
|
||||
// Sync usage limits for the new subscription
|
||||
try {
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
} catch (error) {
|
||||
@@ -1241,7 +1217,6 @@ export const auth = betterAuth({
|
||||
})
|
||||
}
|
||||
|
||||
// Send welcome email for Pro and Team plans
|
||||
try {
|
||||
const { sendPlanWelcomeEmail } = await import('@/lib/billing')
|
||||
await sendPlanWelcomeEmail(subscription)
|
||||
@@ -1286,9 +1261,7 @@ export const auth = betterAuth({
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
// Reset usage limits back to free tier defaults
|
||||
try {
|
||||
// This will sync limits based on the now-inactive subscription (defaulting to free tier)
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
|
||||
logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', {
|
||||
@@ -1311,7 +1284,6 @@ export const auth = betterAuth({
|
||||
})
|
||||
|
||||
try {
|
||||
// Handle invoice events
|
||||
switch (event.type) {
|
||||
case 'invoice.payment_succeeded': {
|
||||
await handleInvoicePaymentSucceeded(event)
|
||||
@@ -1347,13 +1319,11 @@ export const auth = betterAuth({
|
||||
eventType: event.type,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure to Stripe
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}),
|
||||
// Add organization plugin as a separate entry in the plugins array
|
||||
organization({
|
||||
// Allow team plan subscribers to create organizations
|
||||
allowUserToCreateOrganization: async (user) => {
|
||||
const dbSubscriptions = await db
|
||||
.select()
|
||||
@@ -1457,11 +1427,9 @@ export const auth = betterAuth({
|
||||
signUp: '/signup',
|
||||
error: '/error',
|
||||
verify: '/verify',
|
||||
verifyRequest: '/verify-request',
|
||||
},
|
||||
})
|
||||
|
||||
// Server-side auth helpers
|
||||
export async function getSession() {
|
||||
const hdrs = await headers()
|
||||
return await auth.api.getSession({
|
||||
|
||||
@@ -138,12 +138,7 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
// Check if user needs email verification
|
||||
const requiresVerification = request.cookies.get('requiresEmailVerification')
|
||||
if (requiresVerification?.value === 'true') {
|
||||
return NextResponse.redirect(new URL('/verify', request.url))
|
||||
}
|
||||
|
||||
// Email verification is enforced by Better Auth (server-side). No cookie gating here.
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,6 @@ services:
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-placeholder}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-placeholder}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-placeholder}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-placeholder}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY:-placeholder}
|
||||
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
|
||||
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002}
|
||||
depends_on:
|
||||
|
||||
@@ -14,11 +14,6 @@ services:
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-placeholder}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-placeholder}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-placeholder}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-placeholder}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY:-placeholder}
|
||||
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
|
||||
- SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://localhost:3002}
|
||||
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002}
|
||||
|
||||
Reference in New Issue
Block a user