This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: revert url redirect

+117 -276
+3 -11
src/webapp/app/(auth)/login/page.tsx
··· 1 1 import LoginForm from '@/features/auth/components/loginForm/LoginForm'; 2 2 import { verifySessionOnServer } from '@/lib/auth/dal.server'; 3 - import { sanitizeReturnTo } from '@/lib/auth/returnTo'; 4 3 import { redirect } from 'next/navigation'; 5 4 6 - interface PageProps { 7 - searchParams: Promise<Record<string, string | string[] | undefined>>; 8 - } 9 - 10 - export default async function Page(props: PageProps) { 11 - const params = await props.searchParams; 12 - const returnTo = sanitizeReturnTo(params.returnTo); 13 - 5 + export default async function Page() { 14 6 const session = await verifySessionOnServer(); 15 7 16 - if (session) redirect(returnTo ?? '/home'); 8 + if (session) redirect('/home'); 17 9 18 - return <LoginForm returnTo={returnTo ?? undefined} />; 10 + return <LoginForm />; 19 11 }
+3
src/webapp/app/(dashboard)/home/page.tsx
··· 1 1 import HomeContainer from '@/features/home/containers/homeContainer/HomeContainer'; 2 + import { verifySessionOnServer } from '@/lib/auth/dal.server'; 2 3 3 4 export default async function Page() { 5 + await verifySessionOnServer({ redirectOnFail: true }); 6 + 4 7 return <HomeContainer />; 5 8 }
+3
src/webapp/app/(dashboard)/notifications/page.tsx
··· 1 1 import NotificationsContainer from '@/features/notifications/containers/notificationsContainer/NotificationsContainer'; 2 + import { verifySessionOnServer } from '@/lib/auth/dal.server'; 2 3 3 4 export default async function Page() { 5 + await verifySessionOnServer({ redirectOnFail: true }); 6 + 4 7 return <NotificationsContainer />; 5 8 }
+48 -60
src/webapp/app/api/auth/me/route.ts
··· 69 69 } 70 70 console.error('Token refresh error:', error); 71 71 72 - // If this is a refresh failure with a backend 401/403, the refresh 73 - // token is genuinely invalid — clear cookies so the user re-authenticates. 72 + // If this is a refresh failure with backend response, forward the cookie-clearing headers 74 73 if (error.backendResponse) { 75 - const backendStatus = error.backendResponse.status; 76 - const isAuthFailure = backendStatus === 401 || backendStatus === 403; 77 - 78 74 const response = NextResponse.json<AuthResult>( 79 75 { isAuth: false }, 80 - { status: isAuthFailure ? 401 : 500 }, 76 + { status: 500 }, 81 77 ); 82 78 83 - if (isAuthFailure) { 84 - const setCookieHeader = 85 - error.backendResponse.headers.get('set-cookie'); 86 - if (setCookieHeader) { 87 - response.headers.set('Set-Cookie', setCookieHeader); 88 - } else { 89 - response.cookies.delete('accessToken'); 90 - response.cookies.delete('refreshToken'); 91 - } 79 + // Forward the Set-Cookie headers from backend to clear cookies 80 + const setCookieHeader = 81 + error.backendResponse.headers.get('set-cookie'); 82 + if (setCookieHeader) { 83 + response.headers.set('Set-Cookie', setCookieHeader); 92 84 } 93 85 94 86 return response; 95 87 } 96 88 97 - // Transient error (network, timeout) — don't clear cookies so the 98 - // client can retry. The session may still be valid. 89 + // For other errors, clear cookies manually 99 90 if (ENABLE_AUTH_LOGGING) { 100 - console.log( 101 - '[auth/me] Transient refresh error, preserving cookies for retry', 102 - ); 91 + console.log('[auth/me] Clearing cookies due to token refresh error'); 103 92 } 104 - return NextResponse.json<AuthResult>( 93 + const response = NextResponse.json<AuthResult>( 105 94 { isAuth: false }, 106 95 { status: 500 }, 107 96 ); 97 + response.cookies.delete('accessToken'); 98 + response.cookies.delete('refreshToken'); 99 + return response; 108 100 } finally { 109 101 refreshPromise = null; 110 102 } ··· 141 133 `[auth/me] Profile fetch failed with status: ${profileResponse.status}; and message: ${await profileResponse.text()}`, 142 134 ); 143 135 } 144 - 145 - const isAuthFailure = 146 - profileResponse.status === 401 || profileResponse.status === 403; 147 - 136 + // Clear cookies on auth failure 137 + if (ENABLE_AUTH_LOGGING) { 138 + console.log( 139 + '[auth/me] Clearing cookies due to profile fetch failure', 140 + ); 141 + } 148 142 const response = NextResponse.json<AuthResult>( 149 143 { isAuth: false }, 150 144 { status: profileResponse.status }, 151 145 ); 152 - 153 - if (isAuthFailure) { 154 - if (ENABLE_AUTH_LOGGING) { 155 - console.log( 156 - '[auth/me] Clearing cookies due to auth failure on profile fetch', 157 - ); 158 - } 159 - response.cookies.delete('accessToken'); 160 - response.cookies.delete('refreshToken'); 161 - } 162 - 146 + response.cookies.delete('accessToken'); 147 + response.cookies.delete('refreshToken'); 163 148 return response; 164 149 } 165 150 ··· 172 157 return NextResponse.json<AuthResult>({ isAuth: true, user }); 173 158 } catch (error) { 174 159 console.error('Profile fetch error:', error); 160 + // Clear cookies on fetch error too 175 161 if (ENABLE_AUTH_LOGGING) { 176 - console.log( 177 - '[auth/me] Transient profile fetch error, preserving cookies for retry', 178 - ); 162 + console.log('[auth/me] Clearing cookies due to profile fetch error'); 179 163 } 180 - return NextResponse.json<AuthResult>({ isAuth: false }, { status: 500 }); 164 + const response = NextResponse.json<AuthResult>( 165 + { isAuth: false }, 166 + { status: 500 }, 167 + ); 168 + response.cookies.delete('accessToken'); 169 + response.cookies.delete('refreshToken'); 170 + return response; 181 171 } 182 172 } catch (error) { 183 173 console.error('Auth me error:', error); 184 - refreshPromise = null; 174 + refreshPromise = null; // Reset on error 185 175 if (ENABLE_AUTH_LOGGING) { 186 - console.log( 187 - '[auth/me] Unexpected auth error, preserving cookies for retry', 188 - ); 176 + console.log('[auth/me] Clearing cookies due to unexpected auth error'); 189 177 } 190 - return NextResponse.json<AuthResult>({ isAuth: false }, { status: 500 }); 178 + const response = NextResponse.json<AuthResult>( 179 + { isAuth: false }, 180 + { status: 500 }, 181 + ); 182 + response.cookies.delete('accessToken'); 183 + response.cookies.delete('refreshToken'); 184 + return response; 191 185 } 192 186 } 193 187 ··· 234 228 }, 235 229 }); 236 230 237 - const refreshCookies = refreshResponse.headers.getSetCookie(); 238 - 239 231 if (!profileResponse.ok) { 240 - const response = NextResponse.json<AuthResult>( 241 - { isAuth: false }, 242 - { status: 401 }, 243 - ); 244 - for (const cookie of refreshCookies) { 245 - response.headers.append('Set-Cookie', cookie); 246 - } 247 - return response; 232 + return NextResponse.json<AuthResult>({ isAuth: false }, { status: 401 }); 248 233 } 249 234 250 235 const user = await profileResponse.json(); ··· 253 238 `[auth/me] Token refresh and profile fetch successful for user: ${user.handle} (${user.id})`, 254 239 ); 255 240 } 256 - const response = NextResponse.json<AuthResult>({ isAuth: true, user }); 257 - for (const cookie of refreshCookies) { 258 - response.headers.append('Set-Cookie', cookie); 259 - } 260 - return response; 241 + // Return user profile with backend's Set-Cookie headers 242 + return new Response(JSON.stringify({ isAuth: true, user }), { 243 + status: 200, 244 + headers: { 245 + 'Content-Type': 'application/json', 246 + 'Set-Cookie': refreshResponse.headers.get('set-cookie') || '', 247 + }, 248 + }); 261 249 }
+1 -5
src/webapp/components/navigation/guestBottomBar/GuestBottomBar.tsx
··· 1 - 'use client'; 2 - 3 1 import { AppShellFooter, Avatar, Group } from '@mantine/core'; 4 2 import { MdOutlineEmojiNature } from 'react-icons/md'; 5 3 import BottomBarItem from '../bottomBarItem/BottomBarItem'; 6 - import { useLoginUrlWithReturnTo } from '@/lib/auth/useLoginUrlWithReturnTo'; 7 4 8 5 export default function GuestBottomBar() { 9 - const loginUrl = useLoginUrlWithReturnTo(); 10 6 return ( 11 7 <AppShellFooter px={'sm'} pb={'lg'} py={'xs'} hiddenFrom="sm"> 12 8 <Group align="start" justify="space-around" gap={'lg'} h={'100%'}> ··· 15 11 title="Explore" 16 12 icon={MdOutlineEmojiNature} 17 13 /> 18 - <BottomBarItem href={loginUrl} title="Log in" icon={<Avatar />} /> 14 + <BottomBarItem href="/login" title="Log in" icon={<Avatar />} /> 19 15 </Group> 20 16 </AppShellFooter> 21 17 );
+1 -3
src/webapp/components/navigation/guestNavbar/GuestNavbar.tsx
··· 18 18 import NavbarToggle from '../NavbarToggle'; 19 19 import { BiRightArrowAlt, BiSearch } from 'react-icons/bi'; 20 20 import { LinkButton } from '@/components/link/MantineLink'; 21 - import { useLoginUrlWithReturnTo } from '@/lib/auth/useLoginUrlWithReturnTo'; 22 21 23 22 export default function GuestNavbar() { 24 - const loginUrl = useLoginUrlWithReturnTo(); 25 23 return ( 26 24 <AppShellNavbar p={'xs'} style={{ zIndex: 3 }}> 27 25 <Group justify="space-between"> ··· 45 43 <Group gap={'xs'}> 46 44 <LinkButton href="/signup">Sign up</LinkButton> 47 45 <LinkButton 48 - href={loginUrl} 46 + href="/login" 49 47 color="var(--mantine-color-dark-filled)" 50 48 rightSection={<BiRightArrowAlt size={22} />} 51 49 >
+3 -20
src/webapp/features/auth/components/loginForm/LoginForm.tsx
··· 9 9 import OAuthLoginForm from './OAuthLoginForm'; 10 10 import AppPasswordLoginForm from './AppPasswordLoginForm'; 11 11 12 - interface LoginFormProps { 13 - returnTo?: string; 14 - } 15 - 16 - export default function LoginForm({ returnTo }: LoginFormProps = {}) { 12 + export default function LoginForm() { 17 13 const router = useRouter(); 18 14 const { isAuthenticated, refreshAuth } = useAuth(); 19 15 const [isLoading, setIsLoading] = useState(false); 20 16 const [error, setError] = useState(''); 21 17 22 18 const client = createSembleClient(); 23 - const destination = returnTo ?? '/home'; 24 - 25 - const clearReturnToCookie = () => { 26 - document.cookie = 'postLoginReturnTo=; Max-Age=0; Path=/; SameSite=Lax'; 27 - }; 28 19 29 20 useEffect(() => { 30 21 if (isAuthenticated) { 31 - clearReturnToCookie(); 32 - router.push(destination); 22 + router.push('/home'); 33 23 } 34 24 }, [isAuthenticated]); 35 25 ··· 43 33 try { 44 34 setIsLoading(true); 45 35 setError(''); 46 - 47 - if (returnTo) { 48 - // Persist target across the OAuth round-trip since the backend callback 49 - // always redirects to /home; proxy consumes this cookie there. 50 - document.cookie = `postLoginReturnTo=${encodeURIComponent(returnTo)}; Max-Age=600; Path=/; SameSite=Lax`; 51 - } 52 36 53 37 const { authUrl } = await client.initiateOAuthSignIn({ 54 38 handle: form.values.handle.trimEnd(), ··· 80 64 81 65 // Refresh auth state to fetch user profile with new tokens (cookies are set automatically) 82 66 await refreshAuth(); 83 - clearReturnToCookie(); 84 - router.push(destination); 67 + router.push('/home'); 85 68 } catch (err: any) { 86 69 setError(err.message || 'Invalid credentials'); 87 70 } finally {
+12 -3
src/webapp/features/notifications/lib/queries/useUnreadNotificationCount.tsx
··· 2 2 import { getUnreadNotificationCount } from '../dal'; 3 3 import { notificationKeys } from '../notificationKeys'; 4 4 import { useAuth } from '@/hooks/useAuth'; 5 + import { ApiError } from '@/api-client'; 5 6 6 7 export default function useUnreadNotificationCount() { 7 - const { isAuthenticated } = useAuth(); 8 + const { logout } = useAuth(); 8 9 9 10 const query = useQuery({ 10 11 queryKey: notificationKeys.unreadCount(), 11 - queryFn: getUnreadNotificationCount, 12 - enabled: isAuthenticated, 12 + queryFn: async () => { 13 + try { 14 + return await getUnreadNotificationCount(); 15 + } catch (error) { 16 + if (error instanceof ApiError && error.statusCode === 401) { 17 + logout(); 18 + } 19 + throw error; 20 + } 21 + }, 13 22 staleTime: 3000, 14 23 refetchInterval: 20000, 15 24 refetchOnWindowFocus: true,
+2 -4
src/webapp/features/semble/components/sembleActions/GusetSembleActions.tsx
··· 5 5 import { notifications } from '@mantine/notifications'; 6 6 import { TbPlugConnected } from 'react-icons/tb'; 7 7 import { LinkButton } from '@/components/link/MantineLink'; 8 - import { useLoginUrlWithReturnTo } from '@/lib/auth/useLoginUrlWithReturnTo'; 9 8 10 9 interface Props { 11 10 url: string; 12 11 } 13 12 14 13 export default function GuestSembleActions(props: Props) { 15 - const loginUrl = useLoginUrlWithReturnTo(); 16 14 const shareLink = 17 15 typeof window !== 'undefined' 18 16 ? `${window.location.origin}/url?id=${props.url}` ··· 49 47 )} 50 48 </CopyButton> 51 49 <LinkButton 52 - href={loginUrl} 50 + href={'/login'} 53 51 variant="light" 54 52 color="green" 55 53 radius={'xl'} ··· 57 55 > 58 56 Log in to connect 59 57 </LinkButton> 60 - <LinkButton href={loginUrl}>Log in to add</LinkButton> 58 + <LinkButton href={'/login'}>Log in to add</LinkButton> 61 59 </Group> 62 60 ); 63 61 }
+3
src/webapp/features/settings/components/accountSummary/AccountSummary.tsx
··· 1 1 import { Stack, Text } from '@mantine/core'; 2 2 import { createServerSembleClient } from '@/services/server.apiClient'; 3 + import { verifySessionOnServer } from '@/lib/auth/dal.server'; 3 4 import { LinkAvatar } from '@/components/link/MantineLink'; 4 5 5 6 export default async function AccountSummary() { 7 + await verifySessionOnServer({ redirectOnFail: true }); 8 + 6 9 const client = await createServerSembleClient(); 7 10 const profile = await client.getMyProfile(); 8 11
+19 -44
src/webapp/hooks/useAuth.tsx
··· 13 13 import type { GetProfileResponse } from '@/api-client/ApiClient'; 14 14 import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client'; 15 15 import { verifySessionOnClient } from '@/lib/auth/dal'; 16 - import { sanitizeReturnTo } from '@/lib/auth/returnTo'; 17 16 import { usePathname } from 'next/navigation'; 18 17 import posthog from 'posthog-js'; 19 18 import { isInternalUser, isEarlyTester } from '@/lib/userLists'; 20 19 import { shouldCaptureAnalytics } from '@/features/analytics/utils'; 21 20 import { ENABLE_AUTH_LOGGING } from '@/lib/auth/constants'; 22 21 23 - interface LogoutOptions { 24 - returnTo?: string; 25 - } 26 - 27 22 interface AuthContextType { 28 23 user: GetProfileResponse | null; 29 24 isAuthenticated: boolean; 30 25 isLoading: boolean; 31 26 refreshAuth: () => Promise<void>; 32 - logout: (options?: LogoutOptions) => Promise<void>; 27 + logout: () => Promise<void>; 33 28 } 34 29 35 30 const AuthContext = createContext<AuthContextType | undefined>(undefined); ··· 39 34 const queryClient = useQueryClient(); 40 35 const pathname = usePathname(); // to prevent redirecting to login on landing page 41 36 42 - const logout = useCallback( 43 - async (options?: LogoutOptions) => { 37 + const logout = useCallback(async () => { 38 + if (ENABLE_AUTH_LOGGING) { 39 + console.log('[useAuth] Initiating logout process'); 40 + } 41 + 42 + // Reset PostHog user identity 43 + if (shouldCaptureAnalytics()) { 44 + posthog.reset(); 44 45 if (ENABLE_AUTH_LOGGING) { 45 - console.log('[useAuth] Initiating logout process'); 46 + console.log('[useAuth] PostHog user identity reset'); 46 47 } 48 + } 47 49 48 - // Reset PostHog user identity 49 - if (shouldCaptureAnalytics()) { 50 - posthog.reset(); 51 - if (ENABLE_AUTH_LOGGING) { 52 - console.log('[useAuth] PostHog user identity reset'); 53 - } 54 - } 55 - 56 - await ClientCookieAuthService.clearTokens(); 57 - queryClient.clear(); 58 - 59 - const safeReturnTo = sanitizeReturnTo(options?.returnTo); 60 - if (safeReturnTo) { 61 - router.push(`/login?returnTo=${encodeURIComponent(safeReturnTo)}`); 62 - } else { 63 - router.push('/login'); 64 - } 65 - }, 66 - [queryClient, router], 67 - ); 50 + await ClientCookieAuthService.clearTokens(); 51 + queryClient.clear(); 52 + router.push('/login'); 53 + }, [queryClient, router]); 68 54 69 55 const query = useQuery<GetProfileResponse | null>({ 70 56 queryKey: ['authenticated user'], ··· 74 60 }, 75 61 staleTime: 5 * 60 * 1000, // cache for 5 minutes 76 62 refetchOnWindowFocus: false, 77 - retry: 2, 78 - retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000), 63 + retry: false, 79 64 }); 80 65 81 66 const refreshAuth = useCallback(async () => { ··· 83 68 }, [query.refetch]); 84 69 85 70 useEffect(() => { 86 - // query.data === null means /api/auth/me returned 401 (genuinely not 87 - // authenticated). query.isError means a transient failure (500, network) 88 - // after all retries — don't logout for that, the session may still be valid. 89 - const isAuthPage = 90 - pathname === '/login' || pathname === '/signup' || pathname === '/logout'; 91 - if ( 92 - query.data === null && 93 - !query.isLoading && 94 - pathname !== '/' && 95 - !isAuthPage 96 - ) 97 - logout({ returnTo: pathname ?? undefined }); 98 - }, [query.data, query.isLoading, pathname, logout]); 71 + // Handle other auth errors 72 + if (query.isError && !query.isLoading && pathname !== '/') logout(); 73 + }, [query.isError, query.isLoading, pathname, logout]); 99 74 100 75 // Set super properties for anonymous tracking (no PII) 101 76 useEffect(() => {
+14 -2
src/webapp/lib/auth/dal.server.ts
··· 1 1 import { GetProfileResponse } from '@/api-client/ApiClient'; 2 2 import { cookies } from 'next/headers'; 3 + import { redirect } from 'next/navigation'; 3 4 import { cache } from 'react'; 4 5 5 6 const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://127.0.0.1:4000'; 6 7 7 - export const verifySessionOnServer = cache(async () => { 8 + interface Options { 9 + redirectOnFail?: boolean; 10 + } 11 + 12 + export const verifySessionOnServer = cache(async (options?: Options) => { 8 13 const cookieStore = await cookies(); 9 14 const accessToken = cookieStore.get('accessToken')?.value; 10 15 const refreshToken = cookieStore.get('refreshToken')?.value; 11 16 17 + // tokens are missing 12 18 if (!accessToken || !refreshToken) { 19 + if (options?.redirectOnFail) { 20 + redirect('/login'); 21 + } 13 22 return null; 14 23 } 15 24 16 25 const res = await fetch(`${appUrl}/api/auth/me`, { 17 26 headers: { 18 - Cookie: cookieStore.toString(), 27 + Cookie: cookieStore.toString(), // forward user's cookies 19 28 }, 20 29 }); 21 30 22 31 if (!res.ok) { 32 + if (options?.redirectOnFail) { 33 + redirect('/login'); 34 + } 23 35 return null; 24 36 } 25 37
+5 -15
src/webapp/lib/auth/dal.ts
··· 1 1 import type { GetProfileResponse } from '@/api-client/ApiClient'; 2 2 import { cache } from 'react'; 3 3 import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client'; 4 - import { sanitizeReturnTo } from './returnTo'; 5 4 6 5 const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://127.0.0.1:4000'; 7 6 ··· 10 9 11 10 interface VerifySessionOptions { 12 11 redirectOnFail?: boolean; 13 - } 14 - 15 - function buildLoginUrlFromCurrentLocation(): string { 16 - if (typeof window === 'undefined') return '/login'; 17 - const current = window.location.pathname + window.location.search; 18 - const safe = sanitizeReturnTo(current); 19 - return safe ? `/login?returnTo=${encodeURIComponent(safe)}` : '/login'; 20 12 } 21 13 22 14 export const verifySessionOnClient = cache( ··· 39 31 }); 40 32 41 33 if (!response.ok) { 42 - if (response.status === 401) { 43 - if (redirectOnFail && typeof window !== 'undefined') { 44 - window.location.href = buildLoginUrlFromCurrentLocation(); 45 - } 46 - return null; 34 + if (redirectOnFail && typeof window !== 'undefined') { 35 + // Redirect to login only if requested 36 + window.location.href = '/login'; 47 37 } 48 - throw new Error(`Auth check failed: ${response.status}`); 38 + return null; 49 39 } 50 40 51 41 const { user }: { user: GetProfileResponse } = await response.json(); ··· 67 57 export const logoutUser = async (): Promise<void> => { 68 58 await ClientCookieAuthService.clearTokens(); 69 59 if (typeof window !== 'undefined') { 70 - window.location.href = buildLoginUrlFromCurrentLocation(); 60 + window.location.href = '/login'; 71 61 } 72 62 };
-15
src/webapp/lib/auth/returnTo.ts
··· 1 - export function sanitizeReturnTo( 2 - raw: string | string[] | undefined | null, 3 - ): string | null { 4 - if (typeof raw !== 'string' || raw.length === 0) return null; 5 - if (!raw.startsWith('/')) return null; 6 - if (raw.startsWith('//') || raw.startsWith('/\\')) return null; 7 - if ( 8 - raw === '/login' || 9 - raw.startsWith('/login?') || 10 - raw.startsWith('/login#') 11 - ) { 12 - return null; 13 - } 14 - return raw; 15 - }
-23
src/webapp/lib/auth/useLoginUrlWithReturnTo.ts
··· 1 - 'use client'; 2 - 3 - import { usePathname } from 'next/navigation'; 4 - import { useEffect, useState } from 'react'; 5 - import { sanitizeReturnTo } from './returnTo'; 6 - 7 - export function useLoginUrlWithReturnTo(): string { 8 - const pathname = usePathname(); 9 - const [url, setUrl] = useState('/login'); 10 - 11 - useEffect(() => { 12 - if (typeof window === 'undefined') return; 13 - const current = window.location.pathname + window.location.search; 14 - if (!current || current === '/') { 15 - setUrl('/login'); 16 - return; 17 - } 18 - const safe = sanitizeReturnTo(current); 19 - setUrl(safe ? `/login?returnTo=${encodeURIComponent(safe)}` : '/login'); 20 - }, [pathname]); 21 - 22 - return url; 23 - }
-71
src/webapp/proxy.ts
··· 1 - import { NextResponse } from 'next/server'; 2 - import type { NextRequest } from 'next/server'; 3 - import { sanitizeReturnTo } from './lib/auth/returnTo'; 4 - 5 - const RETURN_TO_COOKIE = 'postLoginReturnTo'; 6 - 7 - const PROTECTED_PREFIXES = ['/home', '/notifications', '/settings']; 8 - 9 - function isProtectedPath(pathname: string): boolean { 10 - return PROTECTED_PREFIXES.some( 11 - (p) => pathname === p || pathname.startsWith(`${p}/`), 12 - ); 13 - } 14 - 15 - function hasSessionCookies(request: NextRequest): boolean { 16 - return ( 17 - !!request.cookies.get('accessToken')?.value && 18 - !!request.cookies.get('refreshToken')?.value 19 - ); 20 - } 21 - 22 - function safeDecode(raw: string): string | null { 23 - try { 24 - return decodeURIComponent(raw); 25 - } catch { 26 - return null; 27 - } 28 - } 29 - 30 - export function proxy(request: NextRequest) { 31 - const { pathname, search } = request.nextUrl; 32 - 33 - // Consume the one-shot return-to cookie when the user lands on /home 34 - // after the Bluesky OAuth round-trip. The backend callback hardcodes its 35 - // redirect to /home, so this cookie is how the pre-OAuth destination 36 - // survives the round-trip. 37 - if (pathname === '/home' && hasSessionCookies(request)) { 38 - const raw = request.cookies.get(RETURN_TO_COOKIE)?.value; 39 - if (raw) { 40 - const safe = sanitizeReturnTo(safeDecode(raw)); 41 - if (safe && safe !== '/home') { 42 - const response = NextResponse.redirect(new URL(safe, request.url)); 43 - response.cookies.set(RETURN_TO_COOKIE, '', { path: '/', maxAge: 0 }); 44 - return response; 45 - } 46 - const response = NextResponse.next(); 47 - response.cookies.set(RETURN_TO_COOKIE, '', { path: '/', maxAge: 0 }); 48 - return response; 49 - } 50 - } 51 - 52 - // Gate protected routes. Checking cookie presence here avoids 53 - // rendering the page for unauthenticated users at all, which is the 54 - // recommended Next.js pattern. Expired-but-present tokens pass through 55 - // here and are handled by downstream data fetches. 56 - if (isProtectedPath(pathname) && !hasSessionCookies(request)) { 57 - const returnTo = `${pathname}${search}`; 58 - const safe = sanitizeReturnTo(returnTo); 59 - const loginUrl = new URL( 60 - safe ? `/login?returnTo=${encodeURIComponent(safe)}` : '/login', 61 - request.url, 62 - ); 63 - return NextResponse.redirect(loginUrl); 64 - } 65 - 66 - return NextResponse.next(); 67 - } 68 - 69 - export const config = { 70 - matcher: ['/((?!_next/static|_next/image|favicon.ico|api/).*)'], 71 - };