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:
Waleed
2025-09-17 17:17:37 -07:00
committed by GitHub
parent 9de7a00373
commit 6312df3a07
14 changed files with 55 additions and 249 deletions

View File

@@ -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 }
}

View File

@@ -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'

View File

@@ -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
}
}
console.error('Uncaught login error:', err)

View File

@@ -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)

View File

@@ -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

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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} />
}

View File

@@ -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)

View File

@@ -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} />

View File

@@ -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({

View File

@@ -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()
}

View File

@@ -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:

View File

@@ -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}