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:
James Brunton
2026-03-11 11:53:54 +00:00
committed by GitHub
parent 8bc37bf5ae
commit fa8c52b2be
114 changed files with 12408 additions and 99 deletions
+217
View File
@@ -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>
)
}
+74
View File
@@ -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 }} />
}
+294
View File
@@ -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>
)
}
+240
View File
@@ -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>
)
}
+226
View File
@@ -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
}
}