UNPKG

ultimate-jekyll-manager

Version:
657 lines (539 loc) 21.8 kB
// This file is required by /signin, /signup, and /reset pages since the logic is mostly the same import { FormManager } from '__main_assets__/js/libs/form-manager.js'; // Module export default function (Manager) { // Shortcuts const { webManager } = Manager; // Form manager instance let formManager = null; // Check query string for popup parameter const url = new URL(window.location.href); const useAuthPopup = url.searchParams.get('authPopup') === 'true' || window !== window.top; // Handle DOM ready webManager.dom().ready() .then(async () => { // Log console.log('[Auth] Initialized. useAuthPopup:', useAuthPopup); // Check for authSignout parameter first await handleAuthSignout(webManager); // Initialize the appropriate form based on the page (with autoReady: false) initializePageForm(); // Check for redirect result from OAuth providers BEFORE enabling form // This prevents the form from appearing interactive while redirect is processing const hasRedirectResult = await handleRedirectResult(); // Log console.log('[Auth] hasRedirectResult:', hasRedirectResult); // If redirect result was found, don't enable the form - user will be redirected if (hasRedirectResult) { return; } // Check subdomain auth restrictions and redirect if needed if (checkSubdomainAuth()) { return; } // No redirect pending - enable the form formManager.ready(); // Update auth return URL in all auth-related links updateAuthReturnUrl(); // Setup password visibility toggle setupPasswordToggle(); }); // Initialize the form based on current page function initializePageForm() { const pagePath = document.documentElement.getAttribute('data-page-path'); if (!pagePath) { console.warn('[Auth] No data-page-path attribute found on HTML element'); return; } if (pagePath === '/signin') { initializeSigninForm(); } else if (pagePath === '/signup') { initializeSignupForm(); } else if (pagePath === '/reset') { initializeResetForm(); } } function stateChangeHandler({ state }) { // Hide initializing spinners and show hidden elements when state changes from initializing if (state !== 'initializing') { formManager.$form.querySelectorAll('.form-initializing-spinner').forEach(($el) => { $el.classList.add('d-none'); }); } } // Shared validation for signin/signup forms - only validate when email provider is selected function validateEmailProvider({ data, setError, $submitButton }) { const provider = $submitButton?.getAttribute('data-provider'); if (provider === 'email') { if (!data.email?.trim()) { setError('email', 'Email is required'); } if (!data.password) { setError('password', 'Password is required'); } } } // Shared submit handler factory for signin/signup forms function createAuthSubmitHandler(action, emailHandler) { return async ({ data, $submitButton }) => { const provider = $submitButton?.getAttribute('data-provider'); if (provider === 'email') { await emailHandler(data); } else if (provider) { await signInWithProvider(provider, action); } }; } // Initialize signin form function initializeSigninForm() { formManager = new FormManager('#auth-form', { autoReady: false, // We'll call ready() after checking redirect result allowResubmit: false, submittingText: 'Signing in...', submittedText: 'Signed In!', }); formManager.on('statechange', stateChangeHandler); formManager.on('validation', validateEmailProvider); formManager.on('submit', createAuthSubmitHandler('signin', handleEmailSignin)); } // Initialize signup form function initializeSignupForm() { formManager = new FormManager('#auth-form', { autoReady: false, // We'll call ready() after checking redirect result allowResubmit: false, submittingText: 'Creating account...', submittedText: 'Account Created!', }); formManager.on('statechange', stateChangeHandler); formManager.on('validation', validateEmailProvider); formManager.on('submit', createAuthSubmitHandler('signup', handleEmailSignup)); } // Initialize reset form function initializeResetForm() { formManager = new FormManager('#auth-form', { autoReady: false, // We'll call ready() after checking redirect result allowResubmit: false, submittingText: 'Sending...', submittedText: 'Email Sent!', }); formManager.on('statechange', stateChangeHandler); formManager.on('submit', ({ data }) => handlePasswordReset(data)); } async function handleRedirectResult() { try { // Import Firebase auth functions const { getAuth, getRedirectResult } = await import('@firebase/auth'); const auth = getAuth(); // Check for redirect result const result = await getRedirectResult(auth); // Log results for debugging console.log('[Auth] Redirect result:', result); // If no result, return false to indicate no redirect was processed if (!result || !result.user) { return false; } console.log('[Auth] Successfully authenticated via redirect:', result.user.email); // Determine the provider from the result const providerId = result.providerId || result.user.providerData?.[0]?.providerId || 'unknown'; // Track based on whether this is a new user const isNewUser = result.additionalUserInfo?.isNewUser; const pagePath = document.documentElement.getAttribute('data-page-path'); const isSignupPage = pagePath === '/signup'; if (isNewUser || isSignupPage) { trackSignup(providerId, result.user); formManager.showSuccess('Account created successfully!'); } else { trackLogin(providerId, result.user); formManager.showSuccess('Successfully signed in!'); } // Return true to indicate redirect was successfully processed return true; } catch (error) { // Only capture unexpected errors to Sentry if (!isUserError(error.code)) { webManager.sentry().captureException(new Error('Error handling redirect result', { cause: error })); } // Handle specific OAuth errors if (error.code === 'auth/account-exists-with-different-credential') { formManager.showError('An account already exists with the same email address but different sign-in credentials. Try signing in with a different provider.'); } else if (error.code === 'auth/popup-blocked') { formManager.showError('Popup was blocked. Please allow popups for this site and try again.'); } else if (error.code === 'auth/operation-not-allowed') { formManager.showError('This sign-in method is not enabled.'); } else if (error.code && error.code !== 'auth/cancelled-popup-request') { formManager.showError(`Authentication error: ${error.message}`); } // Return false on error so form becomes interactive for user to try again return false; } } async function handleAuthSignout(webManager) { const url = new URL(window.location.href); const authSignout = url.searchParams.get('authSignout'); if (authSignout !== 'true') { return; } try { // Log console.log('[Auth] Signing out user due to authSignout=true parameter'); // Sign out the user using webManager await webManager.auth().signOut(); // Remove the authSignout parameter from URL to prevent sign-out loop url.searchParams.delete('authSignout'); window.history.replaceState({}, document.title, url.toString()); } catch (error) { console.error('[Auth] Error signing out:', error); } } function checkSubdomainAuth() { // Get the allowSubdomainAuth config value (defaults to true if not set) const allowSubdomainAuth = webManager.config.auth?.config?.allowSubdomainAuth ?? true; // Check if current hostname is a subdomain const hostname = window.location.hostname; const parts = hostname.split('.'); // If hostname has 3 or more parts (e.g., subdomain.site.com), it's a subdomain // Skip localhost and IP addresses const isSubdomain = parts.length >= 3 && !hostname.includes('localhost') && !/^\d+\.\d+\.\d+\.\d+$/.test(hostname); // Log relevant info console.log('[Auth] checkSubdomainAuth - hostname:', hostname, 'parts:', parts, 'isSubdomain:', isSubdomain, 'allowSubdomainAuth:', allowSubdomainAuth); // If subdomain auth is allowed, no need to redirect regardless of current domain if (allowSubdomainAuth) { return false; } // If not a subdomain, no need to redirect if (!isSubdomain) { return false; } // Redirect to apex domain const apexDomain = parts.slice(-2).join('.'); const currentUrl = new URL(window.location.href); currentUrl.hostname = apexDomain; // Log console.log('[Auth] Redirecting to apex domain for authentication:', currentUrl.href); // Perform the redirect window.location.href = currentUrl.href; // Stop further execution return true; } function updateAuthReturnUrl() { const url = new URL(window.location.href); const authReturnUrl = url.searchParams.get('authReturnUrl'); // Quit if no authReturnUrl is provided if (!authReturnUrl) { console.warn('[Auth] No authReturnUrl provided in URL parameters.'); return; } // Update all relevant URLs document.querySelectorAll('a[href]').forEach((link) => { // Only update auth-related links if (!link.href.includes('/signin') && !link.href.includes('/signup') && !link.href.includes('/reset')) { return; } // Update href to include authReturnUrl const href = new URL(link.href, window.location.origin); href.searchParams.set('authReturnUrl', authReturnUrl); link.href = href.toString(); }); } async function attemptEmailSignIn(email, password) { const { getAuth, signInWithEmailAndPassword } = await import('@firebase/auth'); const auth = getAuth(); const userCredential = await signInWithEmailAndPassword(auth, email, password); return userCredential; } async function handleEmailSignup(formData) { // Use the form data passed from the submit event (already validated) // Sanitize email by trimming const email = formData.email?.trim() || ''; const password = formData.password || ''; // Import Firebase auth functions const { getAuth, createUserWithEmailAndPassword } = await import('@firebase/auth'); // Get auth instance and create user const auth = getAuth(); try { const userCredential = await createUserWithEmailAndPassword(auth, email, password); // Track signup trackSignup('email', userCredential.user); // Show success message formManager.showSuccess('Account created successfully!'); } catch (error) { // Handle Firebase-specific errors if (error.code === 'auth/email-already-in-use') { // Try to sign in with the same credentials try { console.log('[Auth] Email already in use, attempting to sign in instead:', email); const userCredential = await attemptEmailSignIn(email, password); // Track this as a login instead of signup trackLogin('email', userCredential.user); // Show success message formManager.showSuccess('Successfully signed in!'); return; } catch (signInError) { // Throw error for outer catch to handle throw new Error('An account with this email already exists'); } } // Re-throw the error to be handled by the form handler throw error; } } async function handleEmailSignin(formData) { // Use the form data passed from the submit event (already validated) // Sanitize email by trimming const email = formData.email?.trim() || ''; const password = formData.password || ''; // Log console.log('[Auth] Attempting email sign-in for:', email); // Sign in with email and password const userCredential = await attemptEmailSignIn(email, password); // Track login trackLogin('email', userCredential.user); // Show success message formManager.showSuccess('Successfully signed in!'); } async function handlePasswordReset(formData) { // Use the form data passed from the submit event (already validated) // Sanitize email by trimming const email = formData.email?.trim() || ''; // Import Firebase auth functions const { getAuth, sendPasswordResetEmail } = await import('@firebase/auth'); // Get auth instance const auth = getAuth(); try { // Send password reset email await sendPasswordResetEmail(auth, email); // Track password reset trackPasswordReset(); // Show success message formManager.showSuccess(`Password reset email sent to ${email}. Please check your inbox.`); } catch (error) { // Handle Firebase-specific errors if (error.code === 'auth/user-not-found') { // For security, we don't reveal if the user exists // Still show success to prevent email enumeration trackPasswordReset(email); // Track as success for security formManager.showSuccess(`If an account exists for ${email}, a password reset email has been sent.`); return; } // Re-throw the error to be handled by the form handler throw error; } } async function signInWithProvider(providerName, action = 'signin') { try { // Import Firebase auth functions and providers const { getAuth, signInWithPopup, signInWithRedirect, GoogleAuthProvider, FacebookAuthProvider, TwitterAuthProvider, GithubAuthProvider } = await import('@firebase/auth'); const auth = getAuth(); let provider; // Create provider based on provider name switch (providerName) { case 'google.com': provider = new GoogleAuthProvider(); break; case 'facebook.com': provider = new FacebookAuthProvider(); break; case 'twitter.com': provider = new TwitterAuthProvider(); break; case 'github.com': provider = new GithubAuthProvider(); break; default: throw new Error(`Unsupported provider: ${providerName}`); } /* @dev-only:start */ { // // Add device_id and device_name for private IP addresses (required by Firebase) // const deviceId = webManager.storage().get('devDeviceId') || crypto.randomUUID(); // webManager.storage().set('devDeviceId', deviceId); // provider.setCustomParameters({ // device_id: deviceId, // device_name: navigator.userAgent.substring(0, 100), // }); // Show warning in dev mode when using redirect if (!useAuthPopup) { webManager.utilities().showNotification( 'OAuth redirect may fail in development. Use localhost:4000 or add ?authPopup=true to the URL', { type: 'warning', timeout: 10000 // Show for 10 seconds } ); // Wait await new Promise(resolve => setTimeout(resolve, 2000)); } } /* @dev-only:end */ // Use popup if query parameter is set, otherwise use redirect if (useAuthPopup) { try { // Try popup const result = await signInWithPopup(auth, provider); console.log('[Auth] Successfully authenticated via popup:', result.user.email); // Track based on whether this is a new user const isNewUser = result.additionalUserInfo?.isNewUser; if (isNewUser || action === 'signup') { trackSignup(providerName, result.user); // Show success message formManager.showSuccess('Account created successfully!'); } else { trackLogin(providerName, result.user); // Show success message formManager.showSuccess('Successfully signed in!'); } } catch (popupError) { // Check if popup was blocked or failed if (popupError.code === 'auth/popup-blocked' || popupError.code === 'auth/popup-closed-by-user' || popupError.code === 'auth/cancelled-popup-request') { console.log('[Auth] Popup failed, falling back to redirect:', popupError.code); // Fallback to redirect await signInWithRedirect(auth, provider); // Note: This will redirect the user away from the page // The handleRedirectResult function will handle the result when they return } else { // Re-throw other errors throw popupError; } } } else { // Use redirect by default console.log('[Auth] Using redirect for authentication'); await signInWithRedirect(auth, provider); // Note: This will redirect the user away from the page // The handleRedirectResult function will handle the result when they return } } catch (error) { // Only capture unexpected errors to Sentry if (!isUserError(error.code)) { webManager.sentry().captureException(new Error('OAuth provider sign-in error', { cause: error })); } // Handle specific errors if (error.code === 'auth/account-exists-with-different-credential') { throw new Error('An account already exists with the same email address but different sign-in credentials. Try signing in with a different provider.'); } else if (error.code === 'auth/invalid-credential') { throw new Error('Invalid credentials. Please try again.'); } else if (error.code === 'auth/operation-not-allowed') { throw new Error('This sign-in method is not enabled. Please contact support.'); } else if (error.code === 'auth/user-disabled') { throw new Error('This account has been disabled. Please contact support.'); } throw error; } } function setupPasswordToggle() { const $toggleButtons = document.querySelectorAll('.uj-password-toggle'); $toggleButtons.forEach($button => { $button.addEventListener('click', () => { // Find the password input in the same input group const $inputGroup = $button.closest('.input-group'); const $passwordInput = $inputGroup.querySelector('input'); if (!$passwordInput) { return; } // Toggle the input type const currentType = $passwordInput.type; const newType = currentType === 'password' ? 'text' : 'password'; $passwordInput.type = newType; // Toggle icon visibility const $showIcon = $button.querySelector('.uj-password-show'); const $hideIcon = $button.querySelector('.uj-password-hide'); if (!$showIcon || !$hideIcon) { return; } if (newType === 'text') { $showIcon.classList.add('d-none'); $hideIcon.classList.remove('d-none'); } else { $showIcon.classList.remove('d-none'); $hideIcon.classList.add('d-none'); } }); }); } // Helper function to determine if an error is a user error function isUserError(errorCode) { const userErrors = [ 'auth/user-not-found', 'auth/wrong-password', 'auth/invalid-credential', 'auth/invalid-email', 'auth/weak-password', 'auth/email-already-in-use', 'auth/user-disabled', 'auth/too-many-requests', 'auth/popup-closed-by-user', 'auth/cancelled-popup-request' ]; return userErrors.includes(errorCode); } function trackLogin(method, user) { const userId = user.uid; const methodName = method.charAt(0).toUpperCase() + method.slice(1); // Google Analytics 4 gtag('event', 'login', { method: method, user_id: userId }); // Facebook Pixel fbq('trackCustom', 'Login', { content_name: `Account Login ${methodName}`, method: method, }); // TikTok Pixel ttq.track('Login', { content_id: `account-login-${method}`, content_type: 'product', content_name: `Account Login ${methodName}` }); } // Analytics tracking functions function trackSignup(method, user) { const userId = user.uid; const methodName = method.charAt(0).toUpperCase() + method.slice(1); // Google Analytics 4 gtag('event', 'sign_up', { method: method, user_id: userId }); // Facebook Pixel fbq('track', 'CompleteRegistration', { content_name: `Account Registration ${methodName}`, method: method, }); // TikTok Pixel ttq.track('CompleteRegistration', { content_id: `account-registration-${method}`, content_type: 'product', content_name: `Account Registration ${methodName}` }); } // Password reset tracking function function trackPasswordReset() { // Google Analytics 4 gtag('event', 'password_reset', { method: 'email', status: 'success' }); // Facebook Pixel fbq('trackCustom', 'PasswordReset', { method: 'email', status: 'success' }); // TikTok Pixel ttq.track('SubmitForm', { content_id: 'password-reset', content_type: 'product', content_name: 'Password Reset' }); } }