mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 12:15:29 +02:00
Add SaaS frontend code (#5879)
# Description of Changes Adds the code for the SaaS frontend as proprietary code to the OSS repo. This version of the code is adapted from 22/1/2026, which was the last SaaS version based on the 'V2' design. This will move us closer to being able to have the OSS products understand whether the user has a SaaS account, and provide the correct UI in those cases.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '@app/auth/supabase'
|
||||
import { Button } from '@mantine/core'
|
||||
import { withBasePath } from '@app/constants/app'
|
||||
|
||||
interface CallbackState {
|
||||
status: 'processing' | 'success' | 'error'
|
||||
message: string
|
||||
details?: Record<string, any>
|
||||
}
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
const [state, setState] = useState<CallbackState>({
|
||||
status: 'processing',
|
||||
message: 'Processing authentication...'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const error = url.searchParams.get('error')
|
||||
const errorDescription = url.searchParams.get('error_description')
|
||||
const next = url.searchParams.get('next') || '/'
|
||||
|
||||
console.log('[Auth Callback Debug] URL parameters:', {
|
||||
hasCode: !!code,
|
||||
hasError: !!error,
|
||||
error,
|
||||
errorDescription,
|
||||
next,
|
||||
fullUrl: window.location.href
|
||||
})
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
console.error('[Auth Callback Debug] OAuth error:', { error, errorDescription })
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Authentication failed: ${errorMsg}`,
|
||||
details: { error, errorDescription }
|
||||
})
|
||||
|
||||
// Redirect to login page after 3 seconds
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
// If PKCE/SSR-style code is present, exchange it for a session
|
||||
if (code) {
|
||||
console.log('[Auth Callback Debug] Exchanging code for session...')
|
||||
|
||||
setState({
|
||||
status: 'processing',
|
||||
message: 'Exchanging authorization code...'
|
||||
})
|
||||
|
||||
const { data, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code)
|
||||
|
||||
if (exchangeError) {
|
||||
console.error('[Auth Callback Debug] Code exchange error:', exchangeError)
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Failed to complete sign in: ${exchangeError.message}`,
|
||||
details: { exchangeError }
|
||||
})
|
||||
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Auth Callback Debug] Code exchange successful:', {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email
|
||||
})
|
||||
|
||||
setState({
|
||||
status: 'success',
|
||||
message: 'Sign in successful! Redirecting...',
|
||||
details: {
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
provider: data.session?.user?.app_metadata?.provider
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// No code present - might already be authenticated
|
||||
console.log('[Auth Callback Debug] No code present, checking existing session...')
|
||||
|
||||
const { data: sessionData } = await supabase.auth.getSession()
|
||||
|
||||
if (sessionData.session) {
|
||||
console.log('[Auth Callback Debug] Existing session found')
|
||||
setState({
|
||||
status: 'success',
|
||||
message: 'Already signed in! Redirecting...'
|
||||
})
|
||||
} else {
|
||||
console.log('[Auth Callback Debug] No session found')
|
||||
setState({
|
||||
status: 'error',
|
||||
message: 'No authentication data found'
|
||||
})
|
||||
setTimeout(() => navigate('/login', { replace: true }), 2000)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to the intended destination
|
||||
const destination = next.startsWith('/') ? next : '/'
|
||||
console.log('[Auth Callback Debug] Redirecting to:', destination)
|
||||
|
||||
setTimeout(() => navigate(destination, { replace: true }), 1500)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Auth Callback Debug] Unexpected error:', err)
|
||||
|
||||
setState({
|
||||
status: 'error',
|
||||
message: `Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
||||
details: { error: err }
|
||||
})
|
||||
|
||||
setTimeout(() => navigate('/login', { replace: true }), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [navigate])
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (state.status) {
|
||||
case 'processing': return 'text-blue-600'
|
||||
case 'success': return 'text-green-600'
|
||||
case 'error': return 'text-red-600'
|
||||
default: return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
switch (state.status) {
|
||||
case 'processing': return 'Signing you in'
|
||||
case 'success': return 'You\'re all set!'
|
||||
case 'error': return 'Authentication failed'
|
||||
default: return 'Authentication'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute -top-32 -left-32 h-96 w-96 rounded-full bg-blue-200/40 blur-3xl"></div>
|
||||
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-emerald-200/40 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className={`w-full max-w-md rounded-2xl bg-white/80 backdrop-blur shadow-xl p-8`}>
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={withBasePath("/branding/StirlingPDFLogoNoTextDark.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="mx-auto mb-5 h-8 opacity-80"
|
||||
/>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{getTitle()}
|
||||
</h1>
|
||||
<p className={`text-base ${getStatusColor()}`}>
|
||||
{state.message}
|
||||
</p>
|
||||
|
||||
{state.status === 'processing' && (
|
||||
<div className="mt-6">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div className="mt-3 h-1 overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="h-full w-1/2 animate-pulse rounded-full bg-blue-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action button - only show if error */}
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{(() => {
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
className="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{import.meta.env.DEV && state.details && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Debug Information
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(state.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
|
||||
import { isToolRoute } from '@app/utils/pathUtils'
|
||||
import HomePage from '@app/pages/HomePage'
|
||||
import Login from '@app/routes/Login'
|
||||
import GuestUserBanner from '@app/components/auth/GuestUserBanner'
|
||||
import { TrialStatusBanner } from '@app/components/shared/TrialStatusBanner'
|
||||
|
||||
export default function Landing() {
|
||||
const { session, loading } = useAuth()
|
||||
const { isAutoAuthenticating, autoAuthError, shouldTriggerAutoAuth } = useAutoAnonymousAuth()
|
||||
const location = useLocation()
|
||||
|
||||
// Check if current path is a tool (prevents premature navigation on first render)
|
||||
const isCurrentPathTool = useMemo(() => isToolRoute(location.pathname), [location.pathname])
|
||||
|
||||
// Match the same guarded bypass used in RequireAuth
|
||||
const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
|
||||
const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true')
|
||||
|
||||
console.log('[Landing] State:', {
|
||||
pathname: location.pathname,
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
isAutoAuthenticating,
|
||||
shouldTriggerAutoAuth,
|
||||
isCurrentPathTool,
|
||||
autoAuthError
|
||||
})
|
||||
|
||||
// Show loading while checking auth, while auto-authenticating, OR while preparing to auto-authenticate
|
||||
// CRITICAL: Also wait if shouldTriggerAutoAuth is true OR if we're on a tool route (prevents navigation before hook evaluates)
|
||||
if (loading || isAutoAuthenticating || (!session && (shouldTriggerAutoAuth || isCurrentPathTool) && !autoAuthError)) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||
<div className="text-gray-600">
|
||||
{isAutoAuthenticating ? 'Setting up your session...' : 'Loading...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If we have a session or dev bypass is enabled, show the main app
|
||||
if (session || devBypassEnabled) {
|
||||
return (
|
||||
<>
|
||||
<GuestUserBanner />
|
||||
<TrialStatusBanner />
|
||||
<HomePage />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// If auto-authentication failed, navigate to login with error state
|
||||
if (autoAuthError && shouldTriggerAutoAuth) {
|
||||
return <Navigate to="/login" replace state={{ autoAuthError, from: location }} />
|
||||
}
|
||||
|
||||
// If we're at home route ("/"), show login directly (marketing/landing page)
|
||||
// Otherwise navigate to login (fixes URL mismatch for tool routes)
|
||||
const isHome = location.pathname === '/' || location.pathname === ''
|
||||
if (isHome) {
|
||||
return <Login />
|
||||
}
|
||||
|
||||
// For non-home routes without auth, navigate to login (preserves from location)
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase, signInAnonymously } from '@app/auth/supabase'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta'
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout'
|
||||
import '@app/routes/authShared/auth.css'
|
||||
import '@app/routes/authShared/saas-auth.css'
|
||||
import GuestSignInButton from '@app/routes/authShared/GuestSignInButton'
|
||||
|
||||
// Import login components
|
||||
import LoginHeader from '@app/routes/login/LoginHeader'
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage'
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'
|
||||
import MagicLinkForm from '@app/routes/login/MagicLinkForm'
|
||||
import OAuthButtons from '@app/routes/login/OAuthButtons'
|
||||
import DividerWithText from '@app/components/shared/DividerWithText'
|
||||
import LoggedInState from '@app/routes/login/LoggedInState'
|
||||
import { absoluteWithBasePath, getBaseUrl } from '@app/constants/app'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { session, loading, refreshSession } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const [isSigningIn, setIsSigningIn] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showMagicLink, setShowMagicLink] = useState(false)
|
||||
const [showEmailForm, setShowEmailForm] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [magicLinkEmail, setMagicLinkEmail] = useState('')
|
||||
// Prefill email from query param (e.g. after password reset)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const emailFromQuery = url.searchParams.get('email')
|
||||
if (emailFromQuery) {
|
||||
setEmail(emailFromQuery)
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t('login.title', 'Sign in')} - Stirling PDF`,
|
||||
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogTitle: `${t('login.title', 'Sign in')} - Stirling PDF`,
|
||||
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`
|
||||
})
|
||||
|
||||
// Show logged in state if authenticated
|
||||
if (session && !loading) {
|
||||
return <LoggedInState />
|
||||
}
|
||||
|
||||
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
const redirectTo = absoluteWithBasePath('/auth/callback')
|
||||
console.log(`[Login] Signing in with ${provider}`)
|
||||
|
||||
const oauthOptions: any = { redirectTo }
|
||||
if (provider === 'apple') {
|
||||
oauthOptions.queryParams = { scope: 'email name' }
|
||||
} else if (provider === 'azure') {
|
||||
oauthOptions.queryParams = { scope: 'openid profile email' }
|
||||
} else {
|
||||
oauthOptions.queryParams = {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: oauthOptions
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(`[Login] ${provider} error:`, error)
|
||||
setError(t('login.failedToSignIn', { provider, message: error.message }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Login] Unexpected error:`, err)
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithEmail = async () => {
|
||||
if (!email || !password) {
|
||||
setError(t('login.pleaseEnterBoth'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[Login] Signing in with email:', email)
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Login] Email sign in error:', error)
|
||||
setError(error.message)
|
||||
} else if (data.user) {
|
||||
console.log('[Login] Email sign in successful')
|
||||
// User will be redirected by the auth state change
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Unexpected error]:', err)
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithMagicLink = async () => {
|
||||
if (!magicLinkEmail) {
|
||||
setError(t('login.pleaseEnterEmail'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[Login] Sending magic link to:', magicLinkEmail)
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email: magicLinkEmail.trim(),
|
||||
options: {
|
||||
emailRedirectTo: absoluteWithBasePath('/auth/callback')
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Login] Magic link error:', error)
|
||||
setError(error.message)
|
||||
} else {
|
||||
setError(null)
|
||||
alert(t('login.magicLinkSent', { email: magicLinkEmail }))
|
||||
setMagicLinkEmail('')
|
||||
setShowMagicLink(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Unexpected error:', err)
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
navigate('/auth/reset')
|
||||
}
|
||||
|
||||
const handleAnonymousSignIn = async () => {
|
||||
try {
|
||||
setIsSigningIn(true)
|
||||
setError(null)
|
||||
console.log('[Login] Signing in anonymously')
|
||||
|
||||
const { data, error } = await signInAnonymously()
|
||||
|
||||
if (error) {
|
||||
console.error('[Login] Anonymous sign in error:', error)
|
||||
setError((error as any)?.message || 'Unknown error')
|
||||
} else if (data.user) {
|
||||
console.log('[Login] Anonymous sign in successful, refreshing session...')
|
||||
|
||||
// Refresh session to ensure backend endpoints are properly synchronized
|
||||
await refreshSession()
|
||||
|
||||
console.log('[Login] Session refreshed, user will be redirected by auth state change')
|
||||
// User will be redirected by the auth state change after session refresh
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Unexpected error:', err)
|
||||
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm}>
|
||||
<LoginHeader title={t('login.login')} subtitle={t('login.subtitle', 'Sign back in to Stirling PDF')} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="fullwidth"
|
||||
/>
|
||||
|
||||
{/* Divider between OAuth and Email */}
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
{/* Sign in with email button (primary color to match signup CTA) */}
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
disabled={isSigningIn}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{t('login.useEmailInstead', 'Sign in with email')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={isSigningIn ? t('login.loggingIn') : t('login.login')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEmailForm && (
|
||||
<div className="auth-section-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('login.forgotPassword', 'Forgot your password?')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider then Guest */}
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
<GuestSignInButton
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningIn}
|
||||
label={isSigningIn ? t('login.signingIn', 'Signing in...') : t('login.signInAnonymously', 'Sign in as a Guest')}
|
||||
/>
|
||||
|
||||
<div className="auth-bottom-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMagicLink(true)}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('login.useMagicLink', 'Sign in with magic link')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/signup')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('signup.signUp', 'Sign up')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Magic link form renders on demand */}
|
||||
{showMagicLink && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<MagicLinkForm
|
||||
showMagicLink={showMagicLink}
|
||||
magicLinkEmail={magicLinkEmail}
|
||||
setMagicLinkEmail={setMagicLinkEmail}
|
||||
setShowMagicLink={setShowMagicLink}
|
||||
onSubmit={signInWithMagicLink}
|
||||
isSubmitting={isSigningIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout'
|
||||
import LoginHeader from '@app/routes/login/LoginHeader'
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage'
|
||||
import SuccessMessage from '@app/routes/login/SuccessMessage'
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'
|
||||
import NavigationLink from '@app/routes/login/NavigationLink'
|
||||
import { supabase } from '@app/auth/supabase'
|
||||
import { absoluteWithBasePath } from '@app/constants/app'
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
|
||||
export default function ResetPassword() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isRecovery, setIsRecovery] = useState(false)
|
||||
const [didUpdate, setDidUpdate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
const type = url.searchParams.get('type')
|
||||
const code = url.searchParams.get('code')
|
||||
|
||||
// Also parse hash params (Supabase puts tokens & type in the hash)
|
||||
const hash = url.hash || ''
|
||||
const hashParams = new URLSearchParams(hash.startsWith('#') ? hash.substring(1) : hash)
|
||||
const hashType = hashParams.get('type')
|
||||
const hashError = hashParams.get('error')
|
||||
const hashErrorDescription = hashParams.get('error_description')
|
||||
|
||||
if (hashError) {
|
||||
// Show a human-readable error and fall back to email-entry form
|
||||
setError(hashErrorDescription || hashError)
|
||||
setIsRecovery(false)
|
||||
}
|
||||
|
||||
// Consider either source (query or hash) to decide if we're in recovery mode
|
||||
const inRecovery = type === 'recovery' || hashType === 'recovery'
|
||||
setIsRecovery(inRecovery)
|
||||
|
||||
// If a PKCE-style code is present, exchange it for a session immediately
|
||||
const tryExchange = async () => {
|
||||
if (code) {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setIsRecovery(false)
|
||||
} else if (data.session) {
|
||||
setIsRecovery(true)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setIsRecovery(false)
|
||||
}
|
||||
} else {
|
||||
// If no code, see if Supabase already set the session from hash
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (data.session && inRecovery) {
|
||||
setIsRecovery(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
void tryExchange()
|
||||
|
||||
// Clear sensitive tokens from the URL hash
|
||||
if (hash.includes('access_token') || hashError) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname + (inRecovery ? '?type=recovery' : ''))
|
||||
}
|
||||
|
||||
// Listen for Supabase auth state changes to confirm recovery state
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((event) => {
|
||||
if (event === 'PASSWORD_RECOVERY') {
|
||||
setIsRecovery(true)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
sub.subscription.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!email) {
|
||||
setError(t('login.pleaseEnterEmail'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
const redirectTo = absoluteWithBasePath('/auth/reset?type=recovery')
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
|
||||
redirectTo
|
||||
})
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else {
|
||||
setSuccess(t('login.passwordResetSent', { email }))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatePassword = async () => {
|
||||
if (!password || !confirmPassword) {
|
||||
setError(t('signup.pleaseFillAllFields'))
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError(t('signup.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('signup.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
const { data, error } = await supabase.auth.updateUser({ password })
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
return
|
||||
}
|
||||
if (data.user) {
|
||||
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'))
|
||||
// Clear the form fields
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
// Show success-only state and then redirect after a short delay
|
||||
setDidUpdate(true)
|
||||
setTimeout(async () => {
|
||||
const { data: sessionData } = await supabase.auth.getSession()
|
||||
const { data: userData } = await supabase.auth.getUser()
|
||||
const derivedEmail = userData.user?.email || email
|
||||
if (sessionData.session) {
|
||||
navigate('/')
|
||||
} else {
|
||||
const query = derivedEmail ? `?email=${encodeURIComponent(derivedEmail)}` : ''
|
||||
navigate(`/login${query}`)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={isRecovery ? t('login.resetYourPassword', 'Reset your password') : t('login.forgotPassword', 'Forgot your password?')} />
|
||||
{!didUpdate && <SuccessMessage success={success} />}
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{didUpdate ? (
|
||||
<>
|
||||
<SuccessMessage success={success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')} />
|
||||
<NavigationLink
|
||||
onClick={() => navigate('/login')}
|
||||
text={t('login.backToSignIn', 'Back to sign in')}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
) : isRecovery ? (
|
||||
<>
|
||||
<div className="auth-fields">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="password" className="auth-label">{t('signup.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="new-password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('signup.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label htmlFor="confirmPassword" className="auth-label">{t('signup.confirmPassword')}</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="new-password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('signup.confirmPasswordPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdatePassword}
|
||||
disabled={isSubmitting || !password || !confirmPassword}
|
||||
className="auth-button"
|
||||
>
|
||||
{isSubmitting ? t('login.sending', 'Sending…') : t('login.updatePassword', 'Update password')}
|
||||
</button>
|
||||
<NavigationLink
|
||||
onClick={() => navigate('/login')}
|
||||
text={t('login.backToSignIn', 'Back to sign in')}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={''}
|
||||
setEmail={setEmail}
|
||||
setPassword={() => {}}
|
||||
onSubmit={handleSendEmail}
|
||||
isSubmitting={isSubmitting}
|
||||
submitButtonText={t('login.sendResetLink', 'Send reset link')}
|
||||
showPasswordField={false}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-3">
|
||||
{t('login.resetHelp', 'Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one.')}
|
||||
</p>
|
||||
<NavigationLink
|
||||
onClick={() => navigate('/login')}
|
||||
text={t('login.backToSignIn', 'Back to sign in')}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { signInAnonymously } from '@app/auth/supabase'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta'
|
||||
import { getBaseUrl } from '@app/constants/app'
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout'
|
||||
import '@app/routes/authShared/auth.css'
|
||||
import '@app/routes/authShared/saas-auth.css'
|
||||
import GuestSignInButton from '@app/routes/authShared/GuestSignInButton'
|
||||
import { alert } from '@app/components/toast'
|
||||
|
||||
// Import signup components
|
||||
import LoginHeader from '@app/routes/login/LoginHeader'
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage'
|
||||
import OAuthButtons from '@app/routes/login/OAuthButtons'
|
||||
import DividerWithText from '@app/components/shared/DividerWithText'
|
||||
import SignupForm from '@app/routes/signup/SignupForm'
|
||||
import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'
|
||||
import { useAuthService } from '@app/routes/signup/AuthService'
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { session, loading, refreshSession } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const [isSigningUp, setIsSigningUp] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showEmailForm, setShowEmailForm] = useState(false)
|
||||
const [name, setName] = useState(undefined as string | undefined)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [agree, setAgree] = useState(true)
|
||||
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({})
|
||||
|
||||
// Check if we were redirected here with an auto-auth error
|
||||
useEffect(() => {
|
||||
const state = location.state as { autoAuthError?: string } | null
|
||||
if (state?.autoAuthError) {
|
||||
setError(`Unable to access tool: ${state.autoAuthError}`)
|
||||
}
|
||||
}, [location.state])
|
||||
|
||||
// Redirect back to original tool URL once session appears (after auto-anon completes)
|
||||
useEffect(() => {
|
||||
if (!loading && session) {
|
||||
const state = location.state as { from?: { pathname?: string; search?: string; hash?: string } } | null
|
||||
const from = state?.from
|
||||
if (from?.pathname && from.pathname !== '/signup' && from.pathname !== '/login') {
|
||||
const target = `${from.pathname}${from.search ?? ''}${from.hash ?? ''}`
|
||||
console.log('[Signup] Session detected, redirecting back to:', target)
|
||||
navigate(target, { replace: true })
|
||||
}
|
||||
}
|
||||
}, [loading, session, location.state, navigate])
|
||||
|
||||
const handleAnonymousSignIn = async () => {
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
|
||||
console.log('[Signup] Initiating anonymous sign-in...')
|
||||
const { data, error } = await signInAnonymously()
|
||||
|
||||
if (error) {
|
||||
console.error('[Signup] Anonymous sign-in error:', error)
|
||||
setError(`Failed to create guest account: ${(error as any)?.message || 'Unknown error'}`)
|
||||
} else if (data.user) {
|
||||
console.log('[Signup] Anonymous sign-in successful, refreshing session...')
|
||||
|
||||
// Refresh session to ensure backend endpoints are properly synchronized
|
||||
await refreshSession()
|
||||
|
||||
console.log('[Signup] Session refreshed, redirecting to home page')
|
||||
// Redirect to home page after successful anonymous login and session refresh
|
||||
navigate('/')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Signup] Anonymous sign-in unexpected error:', err)
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t('signup.title', 'Create an account')} - Stirling PDF`,
|
||||
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`,
|
||||
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`
|
||||
})
|
||||
|
||||
const { validateSignupForm } = useSignupFormValidation()
|
||||
const { signUp, signInWithProvider } = useAuthService()
|
||||
|
||||
const handleSignUp = async () => {
|
||||
const validation = validateSignupForm(email, password, confirmPassword, name)
|
||||
if (!validation.isValid) {
|
||||
setError(validation.error)
|
||||
setFieldErrors(validation.fieldErrors || {})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
setFieldErrors({})
|
||||
|
||||
const result = await signUp(email, password, name)
|
||||
|
||||
if (result.requiresEmailConfirmation) {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('signup.checkEmailConfirmation'),
|
||||
location: 'top-right',
|
||||
isPersistentPopup: true
|
||||
})
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('signup.accountCreatedSuccessfully'),
|
||||
location: 'top-right',
|
||||
durationMs: 3000
|
||||
})
|
||||
setTimeout(() => navigate('/login'), 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Signup] Unexpected error:', err)
|
||||
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderSignIn = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
await signInWithProvider(provider)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm}>
|
||||
<LoginHeader title={t('signup.title')} subtitle={t('signup.subtitle')} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<OAuthButtons
|
||||
onProviderClick={handleProviderSignIn}
|
||||
isSubmitting={isSigningUp}
|
||||
layout="fullwidth"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider between OAuth and Email */}
|
||||
<div style={{ margin: '0.5rem 0' }}>
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
</div>
|
||||
|
||||
{/* Use Email Instead button (toggles email form) */}
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSigningUp}
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{t('signup.useEmailInstead', 'Use Email Instead')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<SignupForm
|
||||
name={name}
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
agree={agree}
|
||||
setName={setName}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
setAgree={setAgree}
|
||||
onSubmit={handleSignUp}
|
||||
isSubmitting={isSigningUp}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="auth-section-sm">
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
</div>
|
||||
|
||||
<GuestSignInButton
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningUp}
|
||||
label={isSigningUp ? t('login.signingIn', 'Signing in...') : t('login.signInAnonymously', 'Sign in as a Guest')}
|
||||
/>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="auth-bottom-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('login.logIn', 'Log In')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.authContainer {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--auth-bg-color-light-only);
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Main area above footer: keep card centered even with footer visible */
|
||||
.authMain {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 95vh;
|
||||
}
|
||||
|
||||
.authCard {
|
||||
width: min(45rem, 96vw);
|
||||
height: min(50.875rem, 96vh);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
background-color: var(--auth-card-bg);
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
max-height: 96vh;
|
||||
}
|
||||
|
||||
.authCardTwoColumns {
|
||||
width: min(73.75rem, 96vw);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.authLeftPanel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.authLeftPanelCentered {
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.authLeftPanelScrollable {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.authLeftPanel::-webkit-scrollbar {
|
||||
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
|
||||
}
|
||||
|
||||
.authContent {
|
||||
max-width: 26.25rem; /* 420px */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.authLeftPanelScrollable .authContent {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
|
||||
import buildLoginSlides from '@app/components/shared/loginSlides';
|
||||
import styles from '@app/routes/authShared/AuthLayout.module.css';
|
||||
import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
|
||||
import Footer from '@app/components/shared/Footer';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode
|
||||
isEmailFormExpanded?: boolean
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children, isEmailFormExpanded = false }: AuthLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const leftPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
const logoVariant = useLogoVariant();
|
||||
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
|
||||
const isOverflowing = useIsOverflowing(leftPanelRef);
|
||||
|
||||
// Use either overflow detection or email form expansion to determine scrollable state
|
||||
const shouldBeScrollable = isOverflowing || isEmailFormExpanded;
|
||||
|
||||
// Force light mode on auth pages
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
|
||||
|
||||
// Set light mode
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
|
||||
|
||||
// Cleanup: restore previous theme when leaving auth pages
|
||||
return () => {
|
||||
if (previousColorScheme) {
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
// Use viewport to avoid hysteresis when the card is already in single-column mode
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
|
||||
const columnWidth = cardWidthIfTwoCols / 2;
|
||||
const tooNarrow = columnWidth < 470;
|
||||
const tooShort = viewportHeight < 740;
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div className={styles.authMain}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||
>
|
||||
<div
|
||||
ref={leftPanelRef}
|
||||
className={`${styles.authLeftPanel} ${shouldBeScrollable ? styles.authLeftPanelScrollable : styles.authLeftPanelCentered}`}
|
||||
>
|
||||
<div className={styles.authContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '100vw', marginTop: 'auto', marginLeft: '-1.5rem', marginRight: '-1.5rem' }}>
|
||||
<Footer forceLightMode={true} analyticsEnabled />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import '@app/routes/authShared/auth.css'
|
||||
import '@app/routes/authShared/saas-auth.css'
|
||||
|
||||
interface GuestSignInButtonProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function GuestSignInButton({ label, onClick, disabled }: GuestSignInButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed auth-guest-button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* SaaS-specific auth styles — imported alongside the base auth.css */
|
||||
|
||||
.oauth-container-fullwidth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.625rem;
|
||||
background-color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auth-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-dropdown-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.auth-dropdown {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
margin-top: 0.5rem;
|
||||
min-width: 16rem;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.auth-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-guest-button {
|
||||
background-color: #ffffff;
|
||||
color: #9c2f30;
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
import '@app/routes/authShared/saas-auth.css';
|
||||
|
||||
interface EmailPasswordFormProps {
|
||||
email: string
|
||||
password: string
|
||||
setEmail: (email: string) => void
|
||||
setPassword: (password: string) => void
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
submitButtonText: string
|
||||
showPasswordField?: boolean
|
||||
fieldErrors?: {
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailPasswordForm({
|
||||
email,
|
||||
password,
|
||||
setEmail,
|
||||
setPassword,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
submitButtonText,
|
||||
showPasswordField = true,
|
||||
fieldErrors = {}
|
||||
}: EmailPasswordFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="auth-fields">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="email" className="auth-label">{t('login.email', 'Email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username email"
|
||||
placeholder={t('login.enterEmail', 'Enter email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<div className="auth-field-error">{fieldErrors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPasswordField && (
|
||||
<div className="auth-field">
|
||||
<label htmlFor="password" className="auth-label">{t('login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="current-password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t('login.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<div className="auth-field-error">{fieldErrors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email || (showPasswordField && !password)}
|
||||
className="auth-button"
|
||||
>
|
||||
{submitButtonText}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
|
||||
export default function LoadingState() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f3f4f6'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '16px' }}>⏳</div>
|
||||
<p style={{ color: '#6b7280' }}>{t('loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
import '@app/routes/authShared/auth.css'
|
||||
import '@app/routes/authShared/saas-auth.css'
|
||||
|
||||
interface MagicLinkFormProps {
|
||||
showMagicLink: boolean
|
||||
magicLinkEmail: string
|
||||
setMagicLinkEmail: (email: string) => void
|
||||
setShowMagicLink: (show: boolean) => void
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export default function MagicLinkForm({
|
||||
showMagicLink,
|
||||
magicLinkEmail,
|
||||
setMagicLinkEmail,
|
||||
setShowMagicLink,
|
||||
onSubmit,
|
||||
isSubmitting
|
||||
}: MagicLinkFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!showMagicLink) {
|
||||
return (
|
||||
<div className="auth-toggle-wrapper">
|
||||
<button
|
||||
onClick={() => { setShowMagicLink(true) }}
|
||||
disabled={isSubmitting}
|
||||
className="auth-toggle-link"
|
||||
>
|
||||
{t('login.useMagicLink')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-magic-row">
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t('login.enterEmailForMagicLink')}
|
||||
value={magicLinkEmail}
|
||||
onChange={(e) => setMagicLinkEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
|
||||
className="auth-input"
|
||||
/>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !magicLinkEmail}
|
||||
className="auth-magic-button"
|
||||
>
|
||||
{isSubmitting ? t('login.sending') : t('login.sendMagicLink')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { oauthProviders } from '@app/constants/authProviders'
|
||||
import { useTranslation } from '@app/hooks/useTranslation'
|
||||
import { Tooltip } from '@app/components/shared/Tooltip'
|
||||
import { withBasePath } from '@app/constants/app'
|
||||
|
||||
// Exports for compatibility with proprietary code
|
||||
export const DEBUG_SHOW_ALL_PROVIDERS = false;
|
||||
export const oauthProviderConfig = {
|
||||
google: { label: 'Google', file: 'google.svg' },
|
||||
github: { label: 'GitHub', file: 'github.svg' },
|
||||
apple: { label: 'Apple', file: 'apple.svg' },
|
||||
azure: { label: 'Microsoft', file: 'microsoft.svg' },
|
||||
};
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
onProviderClick: (provider: 'github' | 'google') => void
|
||||
isSubmitting: boolean
|
||||
layout?: 'vertical' | 'grid' | 'icons' | 'fullwidth'
|
||||
enabledProviders?: string[] // List of enabled provider IDs from backend
|
||||
}
|
||||
|
||||
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders: _enabledProviders = [] }: OAuthButtonsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (layout === 'icons') {
|
||||
return (
|
||||
<div className="oauth-container-icons">
|
||||
{oauthProviders.map((p) => (
|
||||
<Tooltip
|
||||
key={p.id}
|
||||
content={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-icon"
|
||||
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
>
|
||||
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-small ${p.isDisabled ? 'opacity-20' : ''}`}/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className="oauth-container-grid">
|
||||
{oauthProviders.map((p) => (
|
||||
<Tooltip
|
||||
key={p.id}
|
||||
content={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-grid"
|
||||
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
>
|
||||
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-medium ${p.isDisabled ? 'opacity-20' : ''}`}/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'fullwidth') {
|
||||
return (
|
||||
<div className="oauth-container-fullwidth">
|
||||
{oauthProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-fullwidth"
|
||||
title={p.label}
|
||||
>
|
||||
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-medium ${p.isDisabled ? 'opacity-20' : ''}`} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{oauthProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-vertical"
|
||||
title={p.label}
|
||||
>
|
||||
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-tiny ${p.isDisabled ? 'opacity-20' : ''}`} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface SuccessMessageProps {
|
||||
success: string | null
|
||||
}
|
||||
|
||||
export default function SuccessMessage({ success }: SuccessMessageProps) {
|
||||
if (!success) return null
|
||||
|
||||
return (
|
||||
<div className="success-message">
|
||||
<p className="success-message-text">{success}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { supabase } from '@app/auth/supabase'
|
||||
import { absoluteWithBasePath } from '@app/constants/app'
|
||||
|
||||
export const useAuthService = () => {
|
||||
|
||||
const signUp = async (
|
||||
email: string,
|
||||
password: string,
|
||||
name?: string
|
||||
) => {
|
||||
console.log('[Signup] Creating account for:', email)
|
||||
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
options: {
|
||||
emailRedirectTo: absoluteWithBasePath('/auth/callback'),
|
||||
data: { full_name: name }
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Signup] Sign up error:', error)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
console.log('[Signup] Sign up successful:', data.user)
|
||||
return {
|
||||
user: data.user,
|
||||
session: data.session,
|
||||
requiresEmailConfirmation: data.user && !data.session
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unknown error occurred during signup')
|
||||
}
|
||||
|
||||
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: { redirectTo: absoluteWithBasePath('/auth/callback') }
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signUp,
|
||||
signInWithProvider
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user