mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 20:25:28 +02:00
Restructure frontend code to allow for extensions (#4721)
# Description of Changes Move frontend code into `core` folder and add infrastructure for `proprietary` folder to include premium, non-OSS features
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
|
||||
/**
|
||||
* OAuth Callback Handler
|
||||
*
|
||||
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
|
||||
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
|
||||
* We extract it, store in localStorage, and redirect to the home page.
|
||||
*/
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
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');
|
||||
|
||||
if (!token) {
|
||||
console.error('[AuthCallback] No access_token in URL fragment');
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[AuthCallback] JWT stored in localStorage');
|
||||
|
||||
// Refresh session to load user info into state
|
||||
await refreshSession();
|
||||
|
||||
console.log('[AuthCallback] Session refreshed, redirecting to home');
|
||||
|
||||
// Clear the hash from URL and redirect to home page
|
||||
navigate('/', { replace: true });
|
||||
} catch (error) {
|
||||
console.error('[AuthCallback] Error:', error);
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed. Please try again.' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate, refreshSession]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<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">
|
||||
Completing authentication...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@app/auth/UseSession'
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext'
|
||||
import HomePage from '@app/pages/HomePage'
|
||||
import Login from '@app/routes/Login'
|
||||
|
||||
/**
|
||||
* Landing component - Smart router based on authentication status
|
||||
*
|
||||
* If login is disabled: Show HomePage directly (anonymous mode)
|
||||
* If user is authenticated: Show HomePage
|
||||
* 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 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) {
|
||||
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">
|
||||
Loading...
|
||||
</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 />;
|
||||
}
|
||||
|
||||
// If we have a session, show the main app
|
||||
if (session) {
|
||||
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 === '';
|
||||
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,189 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||
|
||||
// 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 OAuthButtons from '@app/routes/login/OAuthButtons';
|
||||
import DividerWithText from '@app/components/shared/DividerWithText';
|
||||
import LoggedInState from '@app/routes/login/LoggedInState';
|
||||
import { BASE_PATH } from '@app/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('');
|
||||
|
||||
// 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 = window.location.origin + BASE_PATH;
|
||||
|
||||
// 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);
|
||||
|
||||
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}`);
|
||||
}
|
||||
} catch (err) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithEmail = async () => {
|
||||
if (!email || !password) {
|
||||
setError(t('login.pleaseEnterBoth') || 'Please enter both email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
|
||||
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);
|
||||
} else if (user && session) {
|
||||
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');
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
navigate('/auth/reset');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('login.login') || 'Sign in'} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
{/* 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(true)}
|
||||
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', 'Login with email')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 signup link */}
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '0.5rem 0 0.25rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/signup')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('signup.signUp', 'Sign up')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
// Import signup components
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
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 { 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;
|
||||
|
||||
// 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 } = useAuthService();
|
||||
|
||||
const handleSignUp = async () => {
|
||||
const validation = validateSignupForm(email, password, confirmPassword);
|
||||
if (!validation.isValid) {
|
||||
setError(validation.error);
|
||||
setFieldErrors(validation.fieldErrors || {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningUp(true);
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
const result = await signUp(email, password, '');
|
||||
|
||||
if (result.user) {
|
||||
// Show success message and redirect to login
|
||||
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' }));
|
||||
} finally {
|
||||
setIsSigningUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('signup.title', 'Create an account')} subtitle={t('signup.subtitle', 'Join Stirling PDF')} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* Signup form - shown immediately */}
|
||||
<SignupForm
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
onSubmit={handleSignUp}
|
||||
isSubmitting={isSigningUp}
|
||||
fieldErrors={fieldErrors}
|
||||
showName={false}
|
||||
showTerms={false}
|
||||
/>
|
||||
|
||||
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
{/* Bottom row - centered */}
|
||||
<div style={{ textAlign: 'center', margin: '0.5rem 0 0.25rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('login.logIn', 'Log In')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.authCardTwoColumns {
|
||||
width: min(73.75rem, 96vw);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.authLeftPanel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.authLeftPanel::-webkit-scrollbar {
|
||||
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
|
||||
}
|
||||
|
||||
.authContent {
|
||||
max-width: 26.25rem; /* 420px */
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
|
||||
import loginSlides from '@app/components/shared/loginSlides';
|
||||
import styles from '@app/routes/authShared/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);
|
||||
|
||||
// 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
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||
style={{ marginBottom: 'auto' }}
|
||||
>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel imageSlides={loginSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
.auth-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem; /* 8px */
|
||||
margin-bottom: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: var(--auth-label-text-light-only);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem; /* 10px 12px */
|
||||
border: 1px solid var(--auth-input-border-light-only);
|
||||
border-radius: 0.625rem; /* 10px */
|
||||
font-size: 0.875rem; /* 14px */
|
||||
background-color: var(--auth-input-bg-light-only);
|
||||
color: var(--auth-input-text-light-only);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
border-color: var(--auth-border-focus-light-only);
|
||||
box-shadow: 0 0 0 3px var(--auth-focus-ring-light-only);
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem; /* 10px 12px */
|
||||
border: none;
|
||||
border-radius: 0.625rem; /* 10px */
|
||||
background-color: var(--auth-button-bg-light-only);
|
||||
color: var(--auth-button-text-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem; /* 12px */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-toggle-wrapper {
|
||||
text-align: center;
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.auth-toggle-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--auth-label-text-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-toggle-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-magic-row {
|
||||
display: flex;
|
||||
gap: 0.5rem; /* 8px */
|
||||
margin-bottom: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.auth-magic-row .auth-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.auth-magic-button {
|
||||
padding: 0.875rem 1rem; /* 14px 16px */
|
||||
border: none;
|
||||
border-radius: 0.625rem; /* 10px */
|
||||
background-color: var(--auth-magic-button-bg-light-only);
|
||||
color: var(--auth-magic-button-text-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-magic-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-terms {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem; /* 8px */
|
||||
margin-bottom: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.auth-checkbox {
|
||||
width: 1rem; /* 16px */
|
||||
height: 1rem; /* 16px */
|
||||
accent-color: #AF3434;
|
||||
}
|
||||
|
||||
.auth-terms-label {
|
||||
font-size: 0.75rem; /* 12px */
|
||||
color: var(--auth-label-text-light-only);
|
||||
}
|
||||
|
||||
.auth-terms-label a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.auth-confirm {
|
||||
overflow: hidden;
|
||||
transition: max-height 240ms ease, opacity 200ms ease;
|
||||
}
|
||||
|
||||
/* OAuth Button Styles */
|
||||
.oauth-container-icons {
|
||||
display: flex;
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.oauth-container-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.oauth-container-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.oauth-button-icon {
|
||||
width: 3.75rem; /* 60px */
|
||||
height: 3.75rem; /* 60px */
|
||||
border-radius: 0.875rem; /* 14px */
|
||||
border: 1px solid var(--auth-input-border-light-only);
|
||||
background: var(--auth-card-bg-light-only);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.oauth-button-icon:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-button-grid {
|
||||
width: 100%;
|
||||
padding: 1rem; /* 16px */
|
||||
border-radius: 0.875rem; /* 14px */
|
||||
border: 1px solid var(--auth-input-border-light-only);
|
||||
background: var(--auth-card-bg-light-only);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.oauth-button-grid:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-button-vertical {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem; /* 16px 16px */
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.75rem; /* 12px */
|
||||
background-color: var(--auth-card-bg-light-only);
|
||||
font-size: 1rem; /* 16px */
|
||||
font-weight: 500;
|
||||
color: var(--auth-text-primary-light-only);
|
||||
cursor: pointer;
|
||||
gap: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.oauth-button-vertical:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-icon-small {
|
||||
width: 1.75rem; /* 28px */
|
||||
height: 1.75rem; /* 28px */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oauth-icon-medium {
|
||||
width: 1.75rem; /* 28px */
|
||||
height: 1.75rem; /* 28px */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oauth-icon-tiny {
|
||||
width: 1.25rem; /* 20px */
|
||||
height: 1.25rem; /* 20px */
|
||||
}
|
||||
|
||||
/* Login Header Styles */
|
||||
.login-header {
|
||||
margin-bottom: 1rem; /* 16px */
|
||||
margin-top: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.login-header-logos {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-bottom: 1.25rem; /* 20px */
|
||||
}
|
||||
|
||||
.login-logo-icon {
|
||||
width: 2.5rem; /* 40px */
|
||||
height: 2.5rem; /* 40px */
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.login-logo-text {
|
||||
height: 1.5rem; /* 24px */
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem; /* 32px */
|
||||
font-weight: 800;
|
||||
color: var(--auth-text-primary-light-only);
|
||||
margin: 0 0 0.375rem; /* 0 0 6px */
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--auth-text-secondary-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Navigation Link Styles */
|
||||
.navigation-link-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.navigation-link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--auth-label-text-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.navigation-link-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Message Styles */
|
||||
.error-message {
|
||||
padding: 1rem; /* 16px */
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
margin-bottom: 1.5rem; /* 24px */
|
||||
}
|
||||
|
||||
.error-message-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
padding: 1rem; /* 16px */
|
||||
background-color: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
margin-bottom: 1.5rem; /* 24px */
|
||||
}
|
||||
|
||||
.success-message-text {
|
||||
color: #059669;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Field-level error styles */
|
||||
.auth-field-error {
|
||||
color: #dc2626;
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
margin-top: 0.125rem; /* 2px */
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.auth-input-error {
|
||||
border-color: #dc2626 !important;
|
||||
}
|
||||
|
||||
.auth-input-error:focus {
|
||||
border-color: #dc2626 !important;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Shared auth styles extracted from inline */
|
||||
.auth-section {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.auth-section-sm {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.auth-bottom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.auth-bottom-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-link-black {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.auth-dot-black {
|
||||
opacity: 0.5;
|
||||
padding: 0 0.5rem;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Email login button - red CTA style matching SaaS version */
|
||||
.auth-cta-button {
|
||||
background-color: #AF3434 !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.auth-cta-button:hover:not(:disabled) {
|
||||
background-color: #9a2e2e !important;
|
||||
}
|
||||
|
||||
.auth-cta-button:disabled {
|
||||
background-color: #AF3434 !important;
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@app/routes/authShared/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.username', 'Username')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
placeholder={t('login.enterUsername', 'Enter username')}
|
||||
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,13 @@
|
||||
interface ErrorMessageProps {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export default function ErrorMessage({ error }: ErrorMessageProps) {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="error-message">
|
||||
<p className="error-message-text">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function LoggedInState() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f3f4f6',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1)',
|
||||
padding: '32px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>✅</div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold', color: '#059669', marginBottom: '8px' }}>
|
||||
{t('login.youAreLoggedIn')}
|
||||
</h1>
|
||||
<p style={{ color: '#6b7280', fontSize: '14px' }}>
|
||||
{t('login.email')}: {user?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||
<p style={{ color: '#6b7280', fontSize: '14px' }}>
|
||||
Redirecting to home...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
interface LoginHeaderProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
|
||||
return (
|
||||
<div className="login-header">
|
||||
<div className="login-header-logos">
|
||||
<img src={`${BASE_PATH}/logo192.png`} alt="Logo" className="login-logo-icon" />
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
|
||||
</div>
|
||||
<h1 className="login-title">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="login-subtitle">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface NavigationLinkProps {
|
||||
onClick: () => void
|
||||
text: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export default function NavigationLink({ onClick, text, isDisabled = false }: NavigationLinkProps) {
|
||||
return (
|
||||
<div className="navigation-link-container">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
className="navigation-link-button"
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
// OAuth provider configuration
|
||||
const oauthProviders = [
|
||||
{ id: 'google', label: 'Google', file: 'google.svg', isDisabled: false },
|
||||
{ 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
|
||||
isSubmitting: boolean
|
||||
layout?: 'vertical' | 'grid' | 'icons'
|
||||
}
|
||||
|
||||
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Filter out disabled providers - don't show them at all
|
||||
const enabledProviders = oauthProviders.filter(p => !p.isDisabled);
|
||||
|
||||
if (layout === 'icons') {
|
||||
return (
|
||||
<div className="oauth-container-icons">
|
||||
{enabledProviders.map((p) => (
|
||||
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting}
|
||||
className="oauth-button-icon"
|
||||
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
>
|
||||
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-small"/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className="oauth-container-grid">
|
||||
{enabledProviders.map((p) => (
|
||||
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting}
|
||||
className="oauth-button-grid"
|
||||
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
|
||||
>
|
||||
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-medium"/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{enabledProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting}
|
||||
className="oauth-button-vertical"
|
||||
title={p.label}
|
||||
>
|
||||
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-tiny" />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
export const useAuthService = () => {
|
||||
|
||||
const signUp = async (
|
||||
email: string,
|
||||
password: string,
|
||||
name: string
|
||||
) => {
|
||||
console.log('[Signup] Creating account for:', email);
|
||||
|
||||
const { user, session, error } = await springAuth.signUp({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
options: {
|
||||
data: { full_name: name },
|
||||
emailRedirectTo: `${BASE_PATH}/auth/callback`
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[Signup] Sign up error:', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
console.log('[Signup] Sign up successful:', user);
|
||||
return {
|
||||
user: user,
|
||||
session: session,
|
||||
requiresEmailConfirmation: user && !session
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
signUp,
|
||||
signInWithProvider
|
||||
};
|
||||
}
|
||||
;
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect } from 'react';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SignupFieldErrors } from '@app/routes/signup/SignupFormValidation';
|
||||
|
||||
interface SignupFormProps {
|
||||
name?: string
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
agree?: boolean
|
||||
setName?: (name: string) => void
|
||||
setEmail: (email: string) => void
|
||||
setPassword: (password: string) => void
|
||||
setConfirmPassword: (password: string) => void
|
||||
setAgree?: (agree: boolean) => void
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
fieldErrors?: SignupFieldErrors
|
||||
showName?: boolean
|
||||
showTerms?: boolean
|
||||
}
|
||||
|
||||
export default function SignupForm({
|
||||
name = '',
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
agree = true,
|
||||
setName,
|
||||
setEmail,
|
||||
setPassword,
|
||||
setConfirmPassword,
|
||||
setAgree,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
fieldErrors = {},
|
||||
showName = false,
|
||||
showTerms = false
|
||||
}: SignupFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const showConfirm = password.length >= 4;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showConfirm && confirmPassword) {
|
||||
setConfirmPassword('');
|
||||
}
|
||||
}, [showConfirm, confirmPassword, setConfirmPassword]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="auth-fields">
|
||||
{showName && (
|
||||
<div className="auth-field">
|
||||
<label htmlFor="name" className="auth-label">{t('signup.name')}</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
placeholder={t('signup.enterName')}
|
||||
value={name}
|
||||
onChange={(e) => setName?.(e.target.value)}
|
||||
className={`auth-input ${fieldErrors.name ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<div className="auth-field-error">{fieldErrors.name}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="auth-field">
|
||||
<label htmlFor="email" className="auth-label">{t('signup.email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('signup.enterEmail')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
|
||||
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<div className="auth-field-error">{fieldErrors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
|
||||
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<div className="auth-field-error">{fieldErrors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden={!showConfirm}
|
||||
className="auth-confirm"
|
||||
style={{ maxHeight: showConfirm ? 96 : 0, opacity: showConfirm ? 1 : 0 }}
|
||||
>
|
||||
<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)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
|
||||
className={`auth-input ${fieldErrors.confirmPassword ? 'auth-input-error' : ''}`}
|
||||
/>
|
||||
{fieldErrors.confirmPassword && (
|
||||
<div className="auth-field-error">{fieldErrors.confirmPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms - only show if showTerms is true */}
|
||||
{showTerms && (
|
||||
<div className="auth-terms">
|
||||
<input
|
||||
id="agree"
|
||||
type="checkbox"
|
||||
checked={agree}
|
||||
onChange={(e) => setAgree?.(e.target.checked)}
|
||||
className="auth-checkbox"
|
||||
/>
|
||||
<label htmlFor="agree" className="auth-terms-label">
|
||||
{t("legal.iAgreeToThe", 'I agree to all of the')} {" "}
|
||||
<a href="https://www.stirlingpdf.com/terms" target="_blank" rel="noopener noreferrer">
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sign Up Button */}
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !email || !password || !confirmPassword || (showTerms && !agree)}
|
||||
className="auth-button"
|
||||
>
|
||||
{isSubmitting ? t('signup.creatingAccount') : t('signup.signUp')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface SignupFieldErrors {
|
||||
name?: string
|
||||
email?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
}
|
||||
|
||||
export interface SignupValidationResult {
|
||||
isValid: boolean
|
||||
error: string | null
|
||||
fieldErrors?: SignupFieldErrors
|
||||
}
|
||||
|
||||
export const useSignupFormValidation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const validateSignupForm = (
|
||||
email: string,
|
||||
password: string,
|
||||
confirmPassword: string,
|
||||
name?: string
|
||||
): SignupValidationResult => {
|
||||
const fieldErrors: SignupFieldErrors = {};
|
||||
|
||||
// Validate name
|
||||
if (name !== undefined && name !== null && !name.trim()) {
|
||||
fieldErrors.name = t('signup.nameRequired', 'Name is required');
|
||||
}
|
||||
|
||||
// Validate email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!email) {
|
||||
fieldErrors.email = t('signup.emailRequired', 'Email is required');
|
||||
} else if (!emailRegex.test(email)) {
|
||||
fieldErrors.email = t('signup.invalidEmail');
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (!password) {
|
||||
fieldErrors.password = t('signup.passwordRequired', 'Password is required');
|
||||
} else if (password.length < 6) {
|
||||
fieldErrors.password = t('signup.passwordTooShort');
|
||||
}
|
||||
|
||||
// Validate confirm password
|
||||
if (!confirmPassword) {
|
||||
fieldErrors.confirmPassword = t('signup.confirmPasswordRequired', 'Please confirm your password');
|
||||
} else if (password !== confirmPassword) {
|
||||
fieldErrors.confirmPassword = t('signup.passwordsDoNotMatch');
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user