Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/app/reset-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -12,7 +13,7 @@ export default function ResetPasswordPage() {
</h1>

<ResetPasswordForm
onSuccess={() => (window.location.href = '/sign-in')}
onSuccess={() => (window.location.href = getInternalUrl('/sign-in'))}
/>
</div>
</main>
Expand Down
5 changes: 4 additions & 1 deletion src/app/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,7 +37,9 @@ export default function SignUpPage() {
</h1>

<SignUpForm
onSuccess={() => (window.location.href = '/verify-email')}
onSuccess={() =>
(window.location.href = getInternalUrl('/verify-email'))
}
/>

<div className="divider my-6">OR</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import errorHandler, {
ErrorSeverity,
ErrorCategory,
} from '@/utils/error-handler';
import { getInternalUrl } from '@/config/project.config';

interface Props {
children: ReactNode;
Expand Down Expand Up @@ -280,7 +281,7 @@ class ErrorBoundary extends Component<Props, State> {
</button>
{level === 'page' && (
<button
onClick={() => (window.location.href = '/')}
onClick={() => (window.location.href = getInternalUrl('/'))}
className="btn btn-ghost btn-sm"
>
Go Home
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@/lib/auth/rate-limit-check';
import { validateEmail } from '@/lib/auth/email-validator';
import { logAuthEvent } from '@/lib/auth/audit-logger';
import { getRedirectUrl } from '@/config/project.config';

export interface ForgotPasswordFormProps {
/** Callback on success */
Expand Down Expand Up @@ -63,7 +64,7 @@ export default function ForgotPasswordForm({
const { error: resetError } = await supabase.auth.resetPasswordForEmail(
email,
{
redirectTo: `${window.location.origin}/reset-password`,
redirectTo: getRedirectUrl('/reset-password'),
}
);

Expand Down
3 changes: 2 additions & 1 deletion src/components/auth/OAuthButtons/OAuthButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { createLogger } from '@/lib/logger/logger';
import { getRedirectUrl } from '@/config/project.config';

const logger = createLogger('components:auth:OAuthButtons');

Expand Down Expand Up @@ -30,7 +31,7 @@ export default function OAuthButtons({ className = '' }: OAuthButtonsProps) {
await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
redirectTo: getRedirectUrl('/auth/callback'),
},
});
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion src/components/auth/SignInForm/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@/lib/auth/rate-limit-check';
import { validateEmail } from '@/lib/auth/email-validator';
import { logAuthEvent } from '@/lib/auth/audit-logger';
import { getInternalUrl } from '@/config/project.config';
import { createLogger } from '@/lib/logger/logger';

const logger = createLogger('components:auth:SignInForm');
Expand Down Expand Up @@ -91,7 +92,7 @@ export default function SignInForm({
if (signInError) {
// Check if email needs verification
if (signInError.message.toLowerCase().includes('email not confirmed')) {
window.location.href = '/verify-email';
window.location.href = getInternalUrl('/verify-email');
return;
}

Expand Down
48 changes: 48 additions & 0 deletions src/config/__tests__/project.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
getProjectConfig,
isGitHubPages,
getAssetUrl,
getInternalUrl,
getRedirectUrl,
generateManifest,
projectConfig,
} from '../project.config';
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions src/config/project.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 11 additions & 4 deletions src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading