diff --git a/next.config.ts b/next.config.ts index c51e6b1f..847028f2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -50,6 +50,10 @@ const nextConfig: NextConfig = { cleanDistDir: true, env: { NEXT_PUBLIC_PAGESPEED_API_KEY: process.env.NEXT_PUBLIC_PAGESPEED_API_KEY, + // Deploy workflows omit NEXT_PUBLIC_BASE_PATH and rely on detect-project.js, + // so the resolved basePath must be injected here or client code that builds + // URLs (auth redirects) sees '' in production (issue #154). + NEXT_PUBLIC_BASE_PATH: basePath, }, webpack: (config, { isServer }) => { // Optimize code splitting for better performance diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx index f42b0feb..966991db 100644 --- a/src/app/reset-password/page.tsx +++ b/src/app/reset-password/page.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ResetPasswordForm from '@/components/auth/ResetPasswordForm'; +import { getInternalUrl } from '@/config/project.config'; export default function ResetPasswordPage() { return ( @@ -12,7 +13,7 @@ export default function ResetPasswordPage() { (window.location.href = '/sign-in')} + onSuccess={() => (window.location.href = getInternalUrl('/sign-in'))} /> diff --git a/src/app/sign-up/page.tsx b/src/app/sign-up/page.tsx index 86b9b593..fc996499 100644 --- a/src/app/sign-up/page.tsx +++ b/src/app/sign-up/page.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react'; import SignUpForm from '@/components/auth/SignUpForm'; import OAuthButtons from '@/components/auth/OAuthButtons'; import Link from 'next/link'; +import { getInternalUrl } from '@/config/project.config'; function isSafeRedirectUrl(url: string): boolean { if (!url || !url.startsWith('/')) return false; @@ -36,7 +37,9 @@ export default function SignUpPage() { (window.location.href = '/verify-email')} + onSuccess={() => + (window.location.href = getInternalUrl('/verify-email')) + } />
OR
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 1db097e1..ce685f3e 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -6,6 +6,7 @@ import errorHandler, { ErrorSeverity, ErrorCategory, } from '@/utils/error-handler'; +import { getInternalUrl } from '@/config/project.config'; interface Props { children: ReactNode; @@ -280,7 +281,7 @@ class ErrorBoundary extends Component { {level === 'page' && ( + {/* PayPal renders its own SDK Buttons here; Stripe uses the generic + button below. Container is always in the DOM so the SDK has a mount + point, but only visible when PayPal is the active provider. */} +
+ + {/* Payment Button (Stripe / generic redirect flow) */} + {!showPayPalButtons && ( + + )} {/* Provider Info */} {selectedProvider && (

- You will be redirected to{' '} - {selectedProvider === 'stripe' ? 'Stripe' : 'PayPal'} to complete your - payment securely. + {selectedProvider === 'stripe' + ? 'You will be redirected to Stripe to complete your payment securely.' + : 'Complete your payment securely in the PayPal window.'}

)}
diff --git a/src/config/__tests__/project.config.test.ts b/src/config/__tests__/project.config.test.ts index b7b627e4..a5aae2b8 100644 --- a/src/config/__tests__/project.config.test.ts +++ b/src/config/__tests__/project.config.test.ts @@ -3,6 +3,8 @@ import { getProjectConfig, isGitHubPages, getAssetUrl, + getInternalUrl, + getRedirectUrl, generateManifest, projectConfig, } from '../project.config'; @@ -178,6 +180,52 @@ describe('Project Configuration', () => { }); }); + describe('getInternalUrl / getRedirectUrl (issue #154)', () => { + it('prefixes basePath and canonicalizes the trailing slash', () => { + process.env.NEXT_PUBLIC_BASE_PATH = '/RescueDogs'; + + expect(getInternalUrl('/')).toBe('/RescueDogs/'); + expect(getInternalUrl('/auth/callback')).toBe( + '/RescueDogs/auth/callback/' + ); + expect(getInternalUrl('/auth/callback/')).toBe( + '/RescueDogs/auth/callback/' + ); + expect(getInternalUrl('auth/callback')).toBe( + '/RescueDogs/auth/callback/' + ); + }); + + it('degrades to the bare path when basePath is unset', () => { + delete process.env.NEXT_PUBLIC_BASE_PATH; + + expect(getInternalUrl('/')).toBe('/'); + expect(getInternalUrl('/verify-email')).toBe('/verify-email/'); + }); + + it('leaves paths with a query or hash unsuffixed', () => { + process.env.NEXT_PUBLIC_BASE_PATH = '/RescueDogs'; + + expect(getInternalUrl('/sign-in?error=auth_callback_failed')).toBe( + '/RescueDogs/sign-in?error=auth_callback_failed' + ); + expect(getInternalUrl('/page#section')).toBe('/RescueDogs/page#section'); + }); + + it('builds absolute Supabase redirect URLs from the runtime origin plus basePath', () => { + process.env.NEXT_PUBLIC_BASE_PATH = '/RescueDogs'; + + expect(getRedirectUrl('/auth/callback')).toBe( + 'http://localhost:3000/RescueDogs/auth/callback/' + ); + + delete process.env.NEXT_PUBLIC_BASE_PATH; + expect(getRedirectUrl('/auth/callback')).toBe( + 'http://localhost:3000/auth/callback/' + ); + }); + }); + describe('generateManifest', () => { it('should generate a valid PWA manifest', () => { const manifest = generateManifest(); diff --git a/src/config/project.config.ts b/src/config/project.config.ts index cc48dd2b..89470b23 100644 --- a/src/config/project.config.ts +++ b/src/config/project.config.ts @@ -88,6 +88,39 @@ export function getAssetUrl(path: string): string { return `${config.basePath}${cleanPath}`; } +/** + * Normalize a page path: leading slash always; trailing slash unless the + * path carries a query/hash (trailingSlash: true exports emit + * `route/index.html`, so the canonical page URL ends in `/` — hitting the + * slashless form costs a GitHub Pages 301 that Supabase's exact-match + * redirect allow-list won't recognize). + */ +function normalizePagePath(path: string): string { + const withLeading = path.startsWith('/') ? path : `/${path}`; + if (/[?#]/.test(withLeading)) return withLeading; + return withLeading.endsWith('/') ? withLeading : `${withLeading}/`; +} + +/** + * basePath-prefixed path for hard navigations (window.location.href). + * next/navigation's router prepends basePath automatically; raw + * window.location assignments do not — on a GitHub Pages project site a + * bare '/' escapes the app to the domain root (issue #154). + */ +export function getInternalUrl(path: string): string { + const config = getProjectConfig(); + return `${config.basePath}${normalizePagePath(path)}`; +} + +/** + * Absolute URL for Supabase redirect params (emailRedirectTo, redirectTo). + * Client-only: composes the runtime origin with the build-time basePath, + * which is self-consistent for the bundle actually being served. + */ +export function getRedirectUrl(path: string): string { + return `${window.location.origin}${getInternalUrl(path)}`; +} + // Helper function for dynamic manifest generation export function generateManifest() { const config = getProjectConfig(); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 7c96f407..cd7ca4fb 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -16,6 +16,7 @@ import React, { } from 'react'; import { User, Session } from '@supabase/supabase-js'; import { supabase, setAllowAuthTokenRemoval } from '@/lib/supabase/client'; +import { getInternalUrl, getRedirectUrl } from '@/config/project.config'; import { useIdleTimeout } from '@/hooks/useIdleTimeout'; import { retryWithBackoff } from '@/lib/auth/retry-utils'; import { createLogger } from '@/lib/logger'; @@ -233,8 +234,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // sign-out AND the auth-token is gone/expired (we cleared it in // another tab, or the refresh token was revoked server-side). if (_event === 'SIGNED_OUT' && !isLocalSignOut.current) { - logger.info('Cross-tab sign-out detected, redirecting to home'); - window.location.href = '/'; + // Never hijack the confirmation/OAuth landing page: during email + // confirmation no auth-token exists yet, so a transient SIGNED_OUT + // passes the validity guard above — and the callback page owns its + // own error/redirect handling (issue #154). + if (!window.location.pathname.includes('/auth/callback')) { + logger.info('Cross-tab sign-out detected, redirecting to home'); + window.location.href = getInternalUrl('/'); + } return; } @@ -267,7 +274,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { email, password, options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, + emailRedirectTo: getRedirectUrl('/auth/callback'), }, }); return { error }; @@ -313,7 +320,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } // FR-005: Force page reload to clear any stale React state - window.location.href = '/'; + window.location.href = getInternalUrl('/'); }, []); const refreshSession = useCallback(async () => { diff --git a/src/hooks/usePaymentButton.ts b/src/hooks/usePaymentButton.ts index a8c9a64d..54a62176 100644 --- a/src/hooks/usePaymentButton.ts +++ b/src/hooks/usePaymentButton.ts @@ -5,11 +5,15 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { usePaymentConsent } from './usePaymentConsent'; import { createPaymentIntent } from '@/lib/payments/payment-service'; import { createCheckoutSession as createStripeCheckout } from '@/lib/payments/stripe'; -import { createPayPalOrder } from '@/lib/payments/paypal'; +import { + createPayPalOrder, + approvePayPalOrder, + renderPayPalButtons, +} from '@/lib/payments/paypal'; import { getPendingCount } from '@/lib/payments/offline-queue'; import type { Currency, PaymentType, PaymentProvider } from '@/types/payment'; @@ -41,6 +45,13 @@ export interface UsePaymentButtonReturn { consentReady: boolean; selectProvider: (provider: PaymentProvider) => void; initiatePayment: () => Promise; + /** + * Mount PayPal's SDK Buttons into `containerId`. Unlike Stripe (a redirect), + * PayPal one-time payments approve inside PayPal's own popup driven by the + * SDK: createOrder → user approves → onApprove → capture. Call this instead + * of initiatePayment when the PayPal provider is selected. + */ + mountPayPalButtons: (containerId: string) => Promise; clearError: () => void; } @@ -78,6 +89,9 @@ export function usePaymentButton( const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); const [queuedCount, setQueuedCount] = useState(0); + // The PayPal SDK's onApprove only hands us order data, so we stash the + // intent id created in createOrder to report it back via onSuccess. + const pendingIntentId = useRef(null); const { hasConsent, ready: consentReady } = usePaymentConsent(); @@ -104,12 +118,35 @@ export function usePaymentButton( setError(null); }; + // Shared: create the Supabase payment_intent that both providers build on. + const newIntent = () => + createPaymentIntent( + options.amount, + options.currency, + options.type, + options.customerEmail, + { + description: options.description, + metadata: options.metadata, + parent_intent_id: options.parentIntentId, + } + ); + + // Stripe path: create intent → redirect to hosted Checkout. (PayPal does + // NOT go through here — its approval happens in the SDK Buttons popup, + // see mountPayPalButtons.) const initiatePayment = async () => { if (!selectedProvider) { setError(new Error('Please select a payment provider')); return; } - + if (selectedProvider === 'paypal') { + // PayPal is driven by the SDK Buttons, not this click handler. + setError( + new Error('Use the PayPal button to approve payment in PayPal.') + ); + return; + } if (!hasConsent) { setError( new Error('Payment consent required. Please accept the consent modal.') @@ -121,43 +158,67 @@ export function usePaymentButton( setError(null); try { - // Step 1: Create payment intent in Supabase - const intent = await createPaymentIntent( - options.amount, - options.currency, - options.type, - options.customerEmail, - { - description: options.description, - metadata: options.metadata, - parent_intent_id: options.parentIntentId, - } - ); - - // Step 2: Redirect to provider checkout - if (selectedProvider === 'stripe') { - await createStripeCheckout(intent.id); - } else if (selectedProvider === 'paypal') { - await createPayPalOrder(intent.id); - } - - // Success callback - if (options.onSuccess) { - options.onSuccess(intent.id); - } + const intent = await newIntent(); + await createStripeCheckout(intent.id); // navigates away on success + options.onSuccess?.(intent.id); } catch (err) { const errorObj = err instanceof Error ? err : new Error('Payment failed'); setError(errorObj); - - // Error callback - if (options.onError) { - options.onError(errorObj); - } + options.onError?.(errorObj); } finally { setIsProcessing(false); } }; + // PayPal path: render the SDK Buttons. createOrder creates our intent + + // the PayPal order; onApprove captures it via the capture Edge Function. + const mountPayPalButtons = async (containerId: string) => { + if (!hasConsent) { + setError( + new Error('Payment consent required. Please accept the consent modal.') + ); + return; + } + setError(null); + try { + await renderPayPalButtons(containerId, { + createOrder: async () => { + const intent = await newIntent(); + // Stash for onApprove's success callback (SDK only hands us order data). + pendingIntentId.current = intent.id; + return await createPayPalOrder(intent.id); + }, + onApprove: async (data: { orderID?: string }) => { + setIsProcessing(true); + try { + const orderId = data?.orderID; + if (!orderId) throw new Error('PayPal approval missing orderID'); + const result = await approvePayPalOrder(orderId); + if (!result.success) { + throw new Error(result.error || 'PayPal capture failed'); + } + options.onSuccess?.(pendingIntentId.current || orderId); + } catch (err) { + const e = err instanceof Error ? err : new Error('Capture failed'); + setError(e); + options.onError?.(e); + } finally { + setIsProcessing(false); + } + }, + onError: (err: unknown) => { + const e = err instanceof Error ? err : new Error('PayPal error'); + setError(e); + options.onError?.(e); + }, + }); + } catch (err) { + const e = err instanceof Error ? err : new Error('Failed to load PayPal'); + setError(e); + options.onError?.(e); + } + }; + const clearError = () => setError(null); return { @@ -169,6 +230,7 @@ export function usePaymentButton( consentReady, selectProvider, initiatePayment, + mountPayPalButtons, clearError, }; } diff --git a/tests/unit/auth/use-auth.test.tsx b/tests/unit/auth/use-auth.test.tsx index 115871e8..8ef48865 100644 --- a/tests/unit/auth/use-auth.test.tsx +++ b/tests/unit/auth/use-auth.test.tsx @@ -3,7 +3,7 @@ * Tests the useAuth hook and AuthProvider */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; // Mock Supabase client first (this needs to be hoisted) @@ -229,4 +229,131 @@ describe('useAuth', () => { expect(error).toEqual(mockError); }); + + describe('basePath-aware redirects (issue #154)', () => { + const originalBasePath = process.env.NEXT_PUBLIC_BASE_PATH; + + const stubLocation = (pathname: string) => { + // Repo-standard jsdom location stub (see ReAuthModal.test.tsx) — + // records href assignments instead of navigating. + Object.defineProperty(window, 'location', { + value: { + href: `http://localhost:3000${pathname}`, + origin: 'http://localhost:3000', + pathname, + }, + writable: true, + configurable: true, + }); + return window.location; + }; + + beforeEach(() => { + process.env.NEXT_PUBLIC_BASE_PATH = '/RescueDogs'; + localStorage.clear(); + }); + + afterEach(() => { + if (originalBasePath === undefined) { + delete process.env.NEXT_PUBLIC_BASE_PATH; + } else { + process.env.NEXT_PUBLIC_BASE_PATH = originalBasePath; + } + }); + + it('sends a basePath-prefixed emailRedirectTo at sign-up', async () => { + stubLocation('/RescueDogs/sign-up/'); + + const { result } = renderHook(() => useAuth(), { + wrapper: AuthProvider, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await result.current.signUp('test@example.com', 'password123'); + + expect(supabase.auth.signUp).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + options: { + emailRedirectTo: 'http://localhost:3000/RescueDogs/auth/callback/', + }, + }); + }); + + it('redirects signOut to the basePath root, not the domain root', async () => { + const loc = stubLocation('/RescueDogs/profile/'); + + const { result } = renderHook(() => useAuth(), { + wrapper: AuthProvider, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await result.current.signOut(); + + expect(loc.href).toBe('/RescueDogs/'); + }); + + it('redirects a real cross-tab SIGNED_OUT to the basePath root', async () => { + const loc = stubLocation('/RescueDogs/profile/'); + + let authStateCallback: any; + vi.mocked(supabase.auth.onAuthStateChange).mockImplementation( + (callback) => { + authStateCallback = callback; + return { + data: { subscription: { unsubscribe: vi.fn() } }, + } as any; + } + ); + + const { result } = renderHook(() => useAuth(), { + wrapper: AuthProvider, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // No sb-*-auth-token in localStorage, so this SIGNED_OUT is "real" + await authStateCallback('SIGNED_OUT', null); + + expect(loc.href).toBe('/RescueDogs/'); + }); + + it('never redirects a SIGNED_OUT fired on the auth callback page', async () => { + const loc = stubLocation('/RescueDogs/auth/callback/'); + const hrefBefore = loc.href; + + let authStateCallback: any; + vi.mocked(supabase.auth.onAuthStateChange).mockImplementation( + (callback) => { + authStateCallback = callback; + return { + data: { subscription: { unsubscribe: vi.fn() } }, + } as any; + } + ); + + const { result } = renderHook(() => useAuth(), { + wrapper: AuthProvider, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // During email confirmation no auth-token exists yet, so a transient + // SIGNED_OUT passes the validity guard — it must not hijack the + // callback page (the pre-#154 bounce to the domain root). + await authStateCallback('SIGNED_OUT', null); + + expect(loc.href).toBe(hrefBefore); + }); + }); });