Merge branch 'V2' into update-legal-links

This commit is contained in:
ConnorYoh 2025-10-27 16:23:29 +00:00 committed by GitHub
commit 7d9be942f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 274 additions and 274 deletions

View File

@ -1,4 +1,4 @@
import './dividerWithText/DividerWithText.css'
import './dividerWithText/DividerWithText.css';
interface TextDividerProps {
text?: string
@ -10,9 +10,9 @@ interface TextDividerProps {
}
export default function DividerWithText({ text, className = '', style, variant = 'default', respondsToDarkMode = true, opacity }: TextDividerProps) {
const variantClass = variant === 'subcategory' ? 'subcategory' : ''
const themeClass = respondsToDarkMode ? '' : 'force-light'
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style
const variantClass = variant === 'subcategory' ? 'subcategory' : '';
const themeClass = respondsToDarkMode ? '' : 'force-light';
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style;
if (text) {
return (
@ -24,7 +24,7 @@ export default function DividerWithText({ text, className = '', style, variant =
<span className="text-divider__label">{text}</span>
<div className="text-divider__rule" />
</div>
)
);
}
return (
@ -32,5 +32,5 @@ export default function DividerWithText({ text, className = '', style, variant =
className={`h-px my-2.5 ${themeClass} ${className}`}
style={styleWithOpacity}
/>
)
);
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react';
import { BASE_PATH } from '../../constants/app';
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
@ -14,53 +14,53 @@ export default function LoginRightCarousel({
initialSeconds?: number
slideSeconds?: number
}) {
const totalSlides = imageSlides.length
const [index, setIndex] = useState(0)
const mouse = useRef({ x: 0, y: 0 })
const totalSlides = imageSlides.length;
const [index, setIndex] = useState(0);
const mouse = useRef({ x: 0, y: 0 });
const durationsMs = useMemo(() => {
if (imageSlides.length === 0) return []
return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000)
}, [imageSlides, initialSeconds, slideSeconds])
if (imageSlides.length === 0) return [];
return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000);
}, [imageSlides, initialSeconds, slideSeconds]);
useEffect(() => {
if (totalSlides <= 1) return
if (totalSlides <= 1) return;
const timeout = setTimeout(() => {
setIndex((i) => (i + 1) % totalSlides)
}, durationsMs[index] ?? slideSeconds * 1000)
return () => clearTimeout(timeout)
}, [index, totalSlides, durationsMs, slideSeconds])
setIndex((i) => (i + 1) % totalSlides);
}, durationsMs[index] ?? slideSeconds * 1000);
return () => clearTimeout(timeout);
}, [index, totalSlides, durationsMs, slideSeconds]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1
}
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
function TiltImage({ src, alt, enabled, maxDeg = 6 }: { src: string; alt?: string; enabled: boolean; maxDeg?: number }) {
const imgRef = useRef<HTMLImageElement | null>(null)
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const el = imgRef.current
if (!el) return
const el = imgRef.current;
if (!el) return;
let raf = 0
let raf = 0;
const tick = () => {
if (enabled) {
const rotY = (mouse.current.x || 0) * maxDeg
const rotX = -(mouse.current.y || 0) * maxDeg
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`
const rotY = (mouse.current.x || 0) * maxDeg;
const rotX = -(mouse.current.y || 0) * maxDeg;
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`;
} else {
el.style.transform = 'translateY(-2rem)'
el.style.transform = 'translateY(-2rem)';
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [enabled, maxDeg])
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [enabled, maxDeg]);
return (
<img
@ -79,7 +79,7 @@ export default function LoginRightCarousel({
transformOrigin: '50% 50%',
}}
/>
)
);
}
return (
@ -155,5 +155,5 @@ export default function LoginRightCarousel({
))}
</div>
</div>
)
);
}

View File

@ -38,6 +38,6 @@ export const loginSlides: LoginCarouselSlide[] = [
followMouseTilt: true,
tiltMaxDeg: 5,
},
]
];
export default loginSlides
export default loginSlides;

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/UseSession'
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/UseSession';
/**
* OAuth Callback Handler
@ -10,50 +10,50 @@ import { useAuth } from '../auth/UseSession'
* We extract it, store in localStorage, and redirect to the home page.
*/
export default function AuthCallback() {
const navigate = useNavigate()
const { refreshSession } = useAuth()
const navigate = useNavigate();
const { refreshSession } = useAuth();
useEffect(() => {
const handleCallback = async () => {
try {
console.log('[AuthCallback] Handling OAuth callback...')
console.log('[AuthCallback] Handling OAuth callback...');
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1) // Remove '#'
const params = new URLSearchParams(hash)
const token = params.get('access_token')
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get('access_token');
if (!token) {
console.error('[AuthCallback] No access_token in URL fragment')
console.error('[AuthCallback] No access_token in URL fragment');
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' }
})
return
});
return;
}
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token)
console.log('[AuthCallback] JWT stored in localStorage')
localStorage.setItem('stirling_jwt', token);
console.log('[AuthCallback] JWT stored in localStorage');
// Refresh session to load user info into state
await refreshSession()
await refreshSession();
console.log('[AuthCallback] Session refreshed, redirecting to home')
console.log('[AuthCallback] Session refreshed, redirecting to home');
// Clear the hash from URL and redirect to home page
navigate('/', { replace: true })
navigate('/', { replace: true });
} catch (error) {
console.error('[AuthCallback] Error:', error)
console.error('[AuthCallback] Error:', error);
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed. Please try again.' }
})
});
}
}
};
handleCallback()
}, [navigate, refreshSession])
handleCallback();
}, [navigate, refreshSession]);
return (
<div style={{
@ -69,5 +69,5 @@ export default function AuthCallback() {
</div>
</div>
</div>
)
);
}

View File

@ -1,8 +1,8 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../auth/UseSession'
import { useAppConfig } from '../hooks/useAppConfig'
import HomePage from '../pages/HomePage'
import Login from './Login'
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../auth/UseSession';
import { useAppConfig } from '../hooks/useAppConfig';
import HomePage from '../pages/HomePage';
import Login from './Login';
/**
* Landing component - Smart router based on authentication status
@ -12,18 +12,18 @@ import Login from './Login'
* If user is not authenticated: Show Login or redirect to /login
*/
export default function Landing() {
const { session, loading: authLoading } = useAuth()
const { config, loading: configLoading } = useAppConfig()
const location = useLocation()
const { session, loading: authLoading } = useAuth();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const loading = authLoading || configLoading
const loading = authLoading || configLoading;
console.log('[Landing] State:', {
pathname: location.pathname,
loading,
hasSession: !!session,
loginEnabled: config?.enableLogin,
})
});
// Show loading while checking auth and config
if (loading) {
@ -36,27 +36,27 @@ export default function Landing() {
</div>
</div>
</div>
)
);
}
// If login is disabled, show app directly (anonymous mode)
if (config?.enableLogin === false) {
console.debug('[Landing] Login disabled - showing app in anonymous mode')
return <HomePage />
console.debug('[Landing] Login disabled - showing app in anonymous mode');
return <HomePage />;
}
// If we have a session, show the main app
if (session) {
return <HomePage />
return <HomePage />;
}
// 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 === ''
const isHome = location.pathname === '/' || location.pathname === '';
if (isHome) {
return <Login />
return <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />
return <Navigate to="/login" replace state={{ from: location }} />;
}

View File

@ -1,42 +1,42 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { springAuth } from '../auth/springAuthClient'
import { useAuth } from '../auth/UseSession'
import { useTranslation } from 'react-i18next'
import { useDocumentMeta } from '../hooks/useDocumentMeta'
import AuthLayout from './authShared/AuthLayout'
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { springAuth } from '../auth/springAuthClient';
import { useAuth } from '../auth/UseSession';
import { useTranslation } from 'react-i18next';
import { useDocumentMeta } from '../hooks/useDocumentMeta';
import AuthLayout from './authShared/AuthLayout';
// Import login components
import LoginHeader from './login/LoginHeader'
import ErrorMessage from './login/ErrorMessage'
import EmailPasswordForm from './login/EmailPasswordForm'
import OAuthButtons from './login/OAuthButtons'
import DividerWithText from '../components/shared/DividerWithText'
import LoggedInState from './login/LoggedInState'
import { BASE_PATH } from '../constants/app'
import LoginHeader from './login/LoginHeader';
import ErrorMessage from './login/ErrorMessage';
import EmailPasswordForm from './login/EmailPasswordForm';
import OAuthButtons from './login/OAuthButtons';
import DividerWithText from '../components/shared/DividerWithText';
import LoggedInState from './login/LoggedInState';
import { BASE_PATH } from '../constants/app';
export default function Login() {
const navigate = useNavigate()
const { session, loading } = useAuth()
const { t } = useTranslation()
const [isSigningIn, setIsSigningIn] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showEmailForm, setShowEmailForm] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const navigate = useNavigate();
const { session, loading } = useAuth();
const { t } = useTranslation();
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = 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')
const url = new URL(window.location.href);
const emailFromQuery = url.searchParams.get('email');
if (emailFromQuery) {
setEmail(emailFromQuery)
setEmail(emailFromQuery);
}
} catch (_) {
// ignore
}
}, [])
}, []);
const baseUrl = window.location.origin + BASE_PATH;
@ -48,74 +48,74 @@ export default function Login() {
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 />
return <LoggedInState />;
}
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
try {
setIsSigningIn(true)
setError(null)
setIsSigningIn(true);
setError(null);
console.log(`[Login] Signing in with ${provider}`)
console.log(`[Login] Signing in with ${provider}`);
// Redirect to Spring OAuth2 endpoint
const { error } = await springAuth.signInWithOAuth({
provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` }
})
});
if (error) {
console.error(`[Login] ${provider} error:`, error)
setError(t('login.failedToSignIn', { provider, message: error.message }) || `Failed to sign in with ${provider}`)
console.error(`[Login] ${provider} error:`, error);
setError(t('login.failedToSignIn', { provider, message: error.message }) || `Failed to sign in with ${provider}`);
}
} catch (err) {
console.error(`[Login] Unexpected error:`, err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred')
console.error(`[Login] Unexpected error:`, err);
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred');
} finally {
setIsSigningIn(false)
setIsSigningIn(false);
}
}
};
const signInWithEmail = async () => {
if (!email || !password) {
setError(t('login.pleaseEnterBoth') || 'Please enter both email and password')
return
setError(t('login.pleaseEnterBoth') || 'Please enter both email and password');
return;
}
try {
setIsSigningIn(true)
setError(null)
setIsSigningIn(true);
setError(null);
console.log('[Login] Signing in with email:', email)
console.log('[Login] Signing in with email:', email);
const { user, session, error } = await springAuth.signInWithPassword({
email: email.trim(),
password: password
})
});
if (error) {
console.error('[Login] Email sign in error:', error)
setError(error.message)
console.error('[Login] Email sign in error:', error);
setError(error.message);
} else if (user && session) {
console.log('[Login] Email sign in successful')
console.log('[Login] Email sign in successful');
// Auth state will update automatically and Landing will redirect to home
// No need to navigate manually here
}
} catch (err) {
console.error('[Login] Unexpected error:', err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred')
console.error('[Login] Unexpected error:', err);
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred');
} finally {
setIsSigningIn(false)
setIsSigningIn(false);
}
}
};
const handleForgotPassword = () => {
navigate('/auth/reset')
}
navigate('/auth/reset');
};
return (
<AuthLayout>
@ -185,5 +185,5 @@ export default function Login() {
</div>
</AuthLayout>
)
);
}

View File

@ -1,28 +1,28 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useDocumentMeta } from '../hooks/useDocumentMeta'
import AuthLayout from './authShared/AuthLayout'
import './authShared/auth.css'
import { BASE_PATH } from '../constants/app'
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDocumentMeta } from '../hooks/useDocumentMeta';
import AuthLayout from './authShared/AuthLayout';
import './authShared/auth.css';
import { BASE_PATH } from '../constants/app';
// Import signup components
import LoginHeader from './login/LoginHeader'
import ErrorMessage from './login/ErrorMessage'
import DividerWithText from '../components/shared/DividerWithText'
import SignupForm from './signup/SignupForm'
import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation'
import { useAuthService } from './signup/AuthService'
import LoginHeader from './login/LoginHeader';
import ErrorMessage from './login/ErrorMessage';
import DividerWithText from '../components/shared/DividerWithText';
import SignupForm from './signup/SignupForm';
import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation';
import { useAuthService } from './signup/AuthService';
export default function Signup() {
const navigate = useNavigate()
const { t } = useTranslation()
const [isSigningUp, setIsSigningUp] = useState(false)
const [error, setError] = useState<string | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({})
const navigate = useNavigate();
const { t } = useTranslation();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
const baseUrl = window.location.origin + BASE_PATH;
@ -34,38 +34,38 @@ export default function Signup() {
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 } = useAuthService()
const { validateSignupForm } = useSignupFormValidation();
const { signUp } = useAuthService();
const handleSignUp = async () => {
const validation = validateSignupForm(email, password, confirmPassword)
const validation = validateSignupForm(email, password, confirmPassword);
if (!validation.isValid) {
setError(validation.error)
setFieldErrors(validation.fieldErrors || {})
return
setError(validation.error);
setFieldErrors(validation.fieldErrors || {});
return;
}
try {
setIsSigningUp(true)
setError(null)
setFieldErrors({})
setIsSigningUp(true);
setError(null);
setFieldErrors({});
const result = await signUp(email, password, '')
const result = await signUp(email, password, '');
if (result.user) {
// Show success message and redirect to login
setError(null)
setTimeout(() => navigate('/login'), 2000)
setError(null);
setTimeout(() => navigate('/login'), 2000);
}
} catch (err) {
console.error('[Signup] Unexpected error:', err)
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
console.error('[Signup] Unexpected error:', err);
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }));
} finally {
setIsSigningUp(false)
setIsSigningUp(false);
}
}
};
return (
<AuthLayout>
@ -101,5 +101,5 @@ export default function Signup() {
</button>
</div>
</AuthLayout>
)
);
}

View File

@ -1,51 +1,51 @@
import React, { useEffect, useRef, useState } from 'react'
import LoginRightCarousel from '../../components/shared/LoginRightCarousel'
import loginSlides from '../../components/shared/loginSlides'
import styles from './AuthLayout.module.css'
import React, { useEffect, useRef, useState } from 'react';
import LoginRightCarousel from '../../components/shared/LoginRightCarousel';
import loginSlides from '../../components/shared/loginSlides';
import styles from './AuthLayout.module.css';
interface AuthLayoutProps {
children: React.ReactNode
}
export default function AuthLayout({ children }: AuthLayoutProps) {
const cardRef = useRef<HTMLDivElement | null>(null)
const [hideRightPanel, setHideRightPanel] = useState(false)
const cardRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme')
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
// Set light mode
htmlElement.setAttribute('data-mantine-color-scheme', 'light')
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)
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)
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)
}
}, [])
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
};
}, []);
return (
<div className={styles.authContainer}>
@ -64,5 +64,5 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
)}
</div>
</div>
)
);
}

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import '../authShared/auth.css'
import { useTranslation } from 'react-i18next';
import '../authShared/auth.css';
interface EmailPasswordFormProps {
email: string
@ -27,12 +27,12 @@ export default function EmailPasswordForm({
showPasswordField = true,
fieldErrors = {}
}: EmailPasswordFormProps) {
const { t } = useTranslation()
const { t } = useTranslation();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit()
}
e.preventDefault();
onSubmit();
};
return (
<form onSubmit={handleSubmit}>
@ -82,5 +82,5 @@ export default function EmailPasswordForm({
{submitButtonText}
</button>
</form>
)
);
}

View File

@ -3,11 +3,11 @@ interface ErrorMessageProps {
}
export default function ErrorMessage({ error }: ErrorMessageProps) {
if (!error) return null
if (!error) return null;
return (
<div className="error-message">
<p className="error-message-text">{error}</p>
</div>
)
);
}

View File

@ -1,20 +1,20 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../auth/UseSession'
import { useTranslation } from 'react-i18next'
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/UseSession';
import { useTranslation } from 'react-i18next';
export default function LoggedInState() {
const navigate = useNavigate()
const { user } = useAuth()
const { t } = useTranslation()
const navigate = useNavigate();
const { user } = useAuth();
const { t } = useTranslation();
useEffect(() => {
const timer = setTimeout(() => {
navigate('/')
}, 2000)
navigate('/');
}, 2000);
return () => clearTimeout(timer)
}, [navigate])
return () => clearTimeout(timer);
}, [navigate]);
return (
<div style={{
@ -50,5 +50,5 @@ export default function LoggedInState() {
</div>
</div>
</div>
)
);
}

View File

@ -18,5 +18,5 @@ export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
<p className="login-subtitle">{subtitle}</p>
)}
</div>
)
);
}

View File

@ -15,5 +15,5 @@ export default function NavigationLink({ onClick, text, isDisabled = false }: Na
{text}
</button>
</div>
)
);
}

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { BASE_PATH } from '../../constants/app'
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '../../constants/app';
// OAuth provider configuration
const oauthProviders = [
@ -7,7 +7,7 @@ const oauthProviders = [
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
{ id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true },
{ id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true }
]
];
interface OAuthButtonsProps {
onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure') => void
@ -16,10 +16,10 @@ interface OAuthButtonsProps {
}
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) {
const { t } = useTranslation()
const { t } = useTranslation();
// Filter out disabled providers - don't show them at all
const enabledProviders = oauthProviders.filter(p => !p.isDisabled)
const enabledProviders = oauthProviders.filter(p => !p.isDisabled);
if (layout === 'icons') {
return (
@ -37,7 +37,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
</div>
))}
</div>
)
);
}
if (layout === 'grid') {
@ -56,7 +56,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
</div>
))}
</div>
)
);
}
return (
@ -74,5 +74,5 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
</button>
))}
</div>
)
);
}

View File

@ -1,5 +1,5 @@
import { springAuth } from '../../auth/springAuthClient'
import { BASE_PATH } from '../../constants/app'
import { springAuth } from '../../auth/springAuthClient';
import { BASE_PATH } from '../../constants/app';
export const useAuthService = () => {
@ -8,7 +8,7 @@ export const useAuthService = () => {
password: string,
name: string
) => {
console.log('[Signup] Creating account for:', email)
console.log('[Signup] Creating account for:', email);
const { user, session, error } = await springAuth.signUp({
email: email.trim(),
@ -17,38 +17,38 @@ export const useAuthService = () => {
data: { full_name: name },
emailRedirectTo: `${BASE_PATH}/auth/callback`
}
})
});
if (error) {
console.error('[Signup] Sign up error:', error)
throw new Error(error.message)
console.error('[Signup] Sign up error:', error);
throw new Error(error.message);
}
if (user) {
console.log('[Signup] Sign up successful:', user)
console.log('[Signup] Sign up successful:', user);
return {
user: user,
session: session,
requiresEmailConfirmation: user && !session
}
};
}
throw new Error('Unknown error occurred during signup')
}
throw new Error('Unknown error occurred during signup');
};
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const { error } = await springAuth.signInWithOAuth({
provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` }
})
});
if (error) {
throw new Error(error.message)
throw new Error(error.message);
}
}
};
return {
signUp,
signInWithProvider
}
}
};
};

View File

@ -1,7 +1,7 @@
import { useEffect } from 'react'
import '../authShared/auth.css'
import { useTranslation } from 'react-i18next'
import { SignupFieldErrors } from './SignupFormValidation'
import { useEffect } from 'react';
import '../authShared/auth.css';
import { useTranslation } from 'react-i18next';
import { SignupFieldErrors } from './SignupFormValidation';
interface SignupFormProps {
name?: string
@ -38,14 +38,14 @@ export default function SignupForm({
showName = false,
showTerms = false
}: SignupFormProps) {
const { t } = useTranslation()
const showConfirm = password.length >= 4
const { t } = useTranslation();
const showConfirm = password.length >= 4;
useEffect(() => {
if (!showConfirm && confirmPassword) {
setConfirmPassword('')
setConfirmPassword('');
}
}, [showConfirm, confirmPassword, setConfirmPassword])
}, [showConfirm, confirmPassword, setConfirmPassword]);
return (
<>
@ -158,5 +158,5 @@ export default function SignupForm({
{isSubmitting ? t('signup.creatingAccount') : t('signup.signUp')}
</button>
</>
)
);
}

View File

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from 'react-i18next';
export interface SignupFieldErrors {
name?: string
@ -14,7 +14,7 @@ export interface SignupValidationResult {
}
export const useSignupFormValidation = () => {
const { t } = useTranslation()
const { t } = useTranslation();
const validateSignupForm = (
email: string,
@ -22,45 +22,45 @@ export const useSignupFormValidation = () => {
confirmPassword: string,
name?: string
): SignupValidationResult => {
const fieldErrors: SignupFieldErrors = {}
const fieldErrors: SignupFieldErrors = {};
// Validate name
if (name !== undefined && name !== null && !name.trim()) {
fieldErrors.name = t('signup.nameRequired', 'Name is required')
fieldErrors.name = t('signup.nameRequired', 'Name is required');
}
// Validate email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
fieldErrors.email = t('signup.emailRequired', 'Email is required')
fieldErrors.email = t('signup.emailRequired', 'Email is required');
} else if (!emailRegex.test(email)) {
fieldErrors.email = t('signup.invalidEmail')
fieldErrors.email = t('signup.invalidEmail');
}
// Validate password
if (!password) {
fieldErrors.password = t('signup.passwordRequired', 'Password is required')
fieldErrors.password = t('signup.passwordRequired', 'Password is required');
} else if (password.length < 6) {
fieldErrors.password = t('signup.passwordTooShort')
fieldErrors.password = t('signup.passwordTooShort');
}
// Validate confirm password
if (!confirmPassword) {
fieldErrors.confirmPassword = t('signup.confirmPasswordRequired', 'Please confirm your password')
fieldErrors.confirmPassword = t('signup.confirmPasswordRequired', 'Please confirm your password');
} else if (password !== confirmPassword) {
fieldErrors.confirmPassword = t('signup.passwordsDoNotMatch')
fieldErrors.confirmPassword = t('signup.passwordsDoNotMatch');
}
const hasErrors = Object.keys(fieldErrors).length > 0
const hasErrors = Object.keys(fieldErrors).length > 0;
return {
isValid: !hasErrors,
error: null, // Don't show generic error, field errors are more specific
fieldErrors: hasErrors ? fieldErrors : undefined
}
}
};
};
return {
validateSignupForm
}
}
};
};

View File

@ -3,7 +3,7 @@ import path from 'path';
import ts from 'typescript';
import { describe, expect, test } from 'vitest';
const REPO_ROOT = path.join(__dirname, '../../../')
const REPO_ROOT = path.join(__dirname, '../../../');
const SRC_ROOT = path.join(__dirname, '..');
const EN_GB_FILE = path.join(__dirname, '../../public/locales/en-GB/translation.json');