alpha
Login
or
Join now
nandi.uk
/
semble
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
feat: revert url redirect
author
pdelfan
date
3 months ago
(Apr 17, 2026, 1:19 PM -0700)
commit
28f807f9
28f807f9c47a698ce1fc3353e2766690308a125d
parent
875bcb6c
875bcb6c056ed636cc18d7b7aadb0149fea97afa
+117
-276
16 changed files
Expand all
Collapse all
Unified
Split
src
webapp
app
(auth)
login
page.tsx
(dashboard)
home
page.tsx
notifications
page.tsx
api
auth
me
route.ts
components
navigation
guestBottomBar
GuestBottomBar.tsx
guestNavbar
GuestNavbar.tsx
features
auth
components
loginForm
LoginForm.tsx
notifications
lib
queries
useUnreadNotificationCount.tsx
semble
components
sembleActions
GusetSembleActions.tsx
settings
components
accountSummary
AccountSummary.tsx
hooks
useAuth.tsx
lib
auth
dal.server.ts
dal.ts
returnTo.ts
useLoginUrlWithReturnTo.ts
proxy.ts
+3
-11
src/webapp/app/(auth)/login/page.tsx
View file
Reviewed
···
1
1
import LoginForm from '@/features/auth/components/loginForm/LoginForm';
2
2
import { verifySessionOnServer } from '@/lib/auth/dal.server';
3
3
-
import { sanitizeReturnTo } from '@/lib/auth/returnTo';
4
3
import { redirect } from 'next/navigation';
5
4
6
6
-
interface PageProps {
7
7
-
searchParams: Promise<Record<string, string | string[] | undefined>>;
8
8
-
}
9
9
-
10
10
-
export default async function Page(props: PageProps) {
11
11
-
const params = await props.searchParams;
12
12
-
const returnTo = sanitizeReturnTo(params.returnTo);
13
13
-
5
5
+
export default async function Page() {
14
6
const session = await verifySessionOnServer();
15
7
16
16
-
if (session) redirect(returnTo ?? '/home');
8
8
+
if (session) redirect('/home');
17
9
18
18
-
return <LoginForm returnTo={returnTo ?? undefined} />;
10
10
+
return <LoginForm />;
19
11
}
+3
src/webapp/app/(dashboard)/home/page.tsx
View file
Reviewed
···
1
1
import HomeContainer from '@/features/home/containers/homeContainer/HomeContainer';
2
2
+
import { verifySessionOnServer } from '@/lib/auth/dal.server';
2
3
3
4
export default async function Page() {
5
5
+
await verifySessionOnServer({ redirectOnFail: true });
6
6
+
4
7
return <HomeContainer />;
5
8
}
+3
src/webapp/app/(dashboard)/notifications/page.tsx
View file
Reviewed
···
1
1
import NotificationsContainer from '@/features/notifications/containers/notificationsContainer/NotificationsContainer';
2
2
+
import { verifySessionOnServer } from '@/lib/auth/dal.server';
2
3
3
4
export default async function Page() {
5
5
+
await verifySessionOnServer({ redirectOnFail: true });
6
6
+
4
7
return <NotificationsContainer />;
5
8
}
+48
-60
src/webapp/app/api/auth/me/route.ts
View file
Reviewed
···
69
69
}
70
70
console.error('Token refresh error:', error);
71
71
72
72
-
// If this is a refresh failure with a backend 401/403, the refresh
73
73
-
// token is genuinely invalid — clear cookies so the user re-authenticates.
72
72
+
// If this is a refresh failure with backend response, forward the cookie-clearing headers
74
73
if (error.backendResponse) {
75
75
-
const backendStatus = error.backendResponse.status;
76
76
-
const isAuthFailure = backendStatus === 401 || backendStatus === 403;
77
77
-
78
74
const response = NextResponse.json<AuthResult>(
79
75
{ isAuth: false },
80
80
-
{ status: isAuthFailure ? 401 : 500 },
76
76
+
{ status: 500 },
81
77
);
82
78
83
83
-
if (isAuthFailure) {
84
84
-
const setCookieHeader =
85
85
-
error.backendResponse.headers.get('set-cookie');
86
86
-
if (setCookieHeader) {
87
87
-
response.headers.set('Set-Cookie', setCookieHeader);
88
88
-
} else {
89
89
-
response.cookies.delete('accessToken');
90
90
-
response.cookies.delete('refreshToken');
91
91
-
}
79
79
+
// Forward the Set-Cookie headers from backend to clear cookies
80
80
+
const setCookieHeader =
81
81
+
error.backendResponse.headers.get('set-cookie');
82
82
+
if (setCookieHeader) {
83
83
+
response.headers.set('Set-Cookie', setCookieHeader);
92
84
}
93
85
94
86
return response;
95
87
}
96
88
97
97
-
// Transient error (network, timeout) — don't clear cookies so the
98
98
-
// client can retry. The session may still be valid.
89
89
+
// For other errors, clear cookies manually
99
90
if (ENABLE_AUTH_LOGGING) {
100
100
-
console.log(
101
101
-
'[auth/me] Transient refresh error, preserving cookies for retry',
102
102
-
);
91
91
+
console.log('[auth/me] Clearing cookies due to token refresh error');
103
92
}
104
104
-
return NextResponse.json<AuthResult>(
93
93
+
const response = NextResponse.json<AuthResult>(
105
94
{ isAuth: false },
106
95
{ status: 500 },
107
96
);
97
97
+
response.cookies.delete('accessToken');
98
98
+
response.cookies.delete('refreshToken');
99
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
144
-
145
145
-
const isAuthFailure =
146
146
-
profileResponse.status === 401 || profileResponse.status === 403;
147
147
-
136
136
+
// Clear cookies on auth failure
137
137
+
if (ENABLE_AUTH_LOGGING) {
138
138
+
console.log(
139
139
+
'[auth/me] Clearing cookies due to profile fetch failure',
140
140
+
);
141
141
+
}
148
142
const response = NextResponse.json<AuthResult>(
149
143
{ isAuth: false },
150
144
{ status: profileResponse.status },
151
145
);
152
152
-
153
153
-
if (isAuthFailure) {
154
154
-
if (ENABLE_AUTH_LOGGING) {
155
155
-
console.log(
156
156
-
'[auth/me] Clearing cookies due to auth failure on profile fetch',
157
157
-
);
158
158
-
}
159
159
-
response.cookies.delete('accessToken');
160
160
-
response.cookies.delete('refreshToken');
161
161
-
}
162
162
-
146
146
+
response.cookies.delete('accessToken');
147
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
160
+
// Clear cookies on fetch error too
175
161
if (ENABLE_AUTH_LOGGING) {
176
176
-
console.log(
177
177
-
'[auth/me] Transient profile fetch error, preserving cookies for retry',
178
178
-
);
162
162
+
console.log('[auth/me] Clearing cookies due to profile fetch error');
179
163
}
180
180
-
return NextResponse.json<AuthResult>({ isAuth: false }, { status: 500 });
164
164
+
const response = NextResponse.json<AuthResult>(
165
165
+
{ isAuth: false },
166
166
+
{ status: 500 },
167
167
+
);
168
168
+
response.cookies.delete('accessToken');
169
169
+
response.cookies.delete('refreshToken');
170
170
+
return response;
181
171
}
182
172
} catch (error) {
183
173
console.error('Auth me error:', error);
184
184
-
refreshPromise = null;
174
174
+
refreshPromise = null; // Reset on error
185
175
if (ENABLE_AUTH_LOGGING) {
186
186
-
console.log(
187
187
-
'[auth/me] Unexpected auth error, preserving cookies for retry',
188
188
-
);
176
176
+
console.log('[auth/me] Clearing cookies due to unexpected auth error');
189
177
}
190
190
-
return NextResponse.json<AuthResult>({ isAuth: false }, { status: 500 });
178
178
+
const response = NextResponse.json<AuthResult>(
179
179
+
{ isAuth: false },
180
180
+
{ status: 500 },
181
181
+
);
182
182
+
response.cookies.delete('accessToken');
183
183
+
response.cookies.delete('refreshToken');
184
184
+
return response;
191
185
}
192
186
}
193
187
···
234
228
},
235
229
});
236
230
237
237
-
const refreshCookies = refreshResponse.headers.getSetCookie();
238
238
-
239
231
if (!profileResponse.ok) {
240
240
-
const response = NextResponse.json<AuthResult>(
241
241
-
{ isAuth: false },
242
242
-
{ status: 401 },
243
243
-
);
244
244
-
for (const cookie of refreshCookies) {
245
245
-
response.headers.append('Set-Cookie', cookie);
246
246
-
}
247
247
-
return response;
232
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
256
-
const response = NextResponse.json<AuthResult>({ isAuth: true, user });
257
257
-
for (const cookie of refreshCookies) {
258
258
-
response.headers.append('Set-Cookie', cookie);
259
259
-
}
260
260
-
return response;
241
241
+
// Return user profile with backend's Set-Cookie headers
242
242
+
return new Response(JSON.stringify({ isAuth: true, user }), {
243
243
+
status: 200,
244
244
+
headers: {
245
245
+
'Content-Type': 'application/json',
246
246
+
'Set-Cookie': refreshResponse.headers.get('set-cookie') || '',
247
247
+
},
248
248
+
});
261
249
}
+1
-5
src/webapp/components/navigation/guestBottomBar/GuestBottomBar.tsx
View file
Reviewed
···
1
1
-
'use client';
2
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
6
-
import { useLoginUrlWithReturnTo } from '@/lib/auth/useLoginUrlWithReturnTo';
7
4
8
5
export default function GuestBottomBar() {
9
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
18
-
<BottomBarItem href={loginUrl} title="Log in" icon={<Avatar />} />
14
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
View file
Reviewed
···
18
18
import NavbarToggle from '../NavbarToggle';
19
19
import { BiRightArrowAlt, BiSearch } from 'react-icons/bi';
20
20
import { LinkButton } from '@/components/link/MantineLink';
21
21
-
import { useLoginUrlWithReturnTo } from '@/lib/auth/useLoginUrlWithReturnTo';
22
21
23
22
export default function GuestNavbar() {
24
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
48
-
href={loginUrl}
46
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
View file
Reviewed
···
9
9
import OAuthLoginForm from './OAuthLoginForm';
10
10
import AppPasswordLoginForm from './AppPasswordLoginForm';
11
11
12
12
-
interface LoginFormProps {
13
13
-
returnTo?: string;
14
14
-
}
15
15
-
16
16
-
export default function LoginForm({ returnTo }: LoginFormProps = {}) {
12
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
23
-
const destination = returnTo ?? '/home';
24
24
-
25
25
-
const clearReturnToCookie = () => {
26
26
-
document.cookie = 'postLoginReturnTo=; Max-Age=0; Path=/; SameSite=Lax';
27
27
-
};
28
19
29
20
useEffect(() => {
30
21
if (isAuthenticated) {
31
31
-
clearReturnToCookie();
32
32
-
router.push(destination);
22
22
+
router.push('/home');
33
23
}
34
24
}, [isAuthenticated]);
35
25
···
43
33
try {
44
34
setIsLoading(true);
45
35
setError('');
46
46
-
47
47
-
if (returnTo) {
48
48
-
// Persist target across the OAuth round-trip since the backend callback
49
49
-
// always redirects to /home; proxy consumes this cookie there.
50
50
-
document.cookie = `postLoginReturnTo=${encodeURIComponent(returnTo)}; Max-Age=600; Path=/; SameSite=Lax`;
51
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
83
-
clearReturnToCookie();
84
84
-
router.push(destination);
67
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
View file
Reviewed
···
2
2
import { getUnreadNotificationCount } from '../dal';
3
3
import { notificationKeys } from '../notificationKeys';
4
4
import { useAuth } from '@/hooks/useAuth';
5
5
+
import { ApiError } from '@/api-client';
5
6
6
7
export default function useUnreadNotificationCount() {
7
7
-
const { isAuthenticated } = useAuth();
8
8
+
const { logout } = useAuth();
8
9
9
10
const query = useQuery({
10
11
queryKey: notificationKeys.unreadCount(),
11
11
-
queryFn: getUnreadNotificationCount,
12
12
-
enabled: isAuthenticated,
12
12
+
queryFn: async () => {
13
13
+
try {
14
14
+
return await getUnreadNotificationCount();
15
15
+
} catch (error) {
16
16
+
if (error instanceof ApiError && error.statusCode === 401) {
17
17
+
logout();
18
18
+
}
19
19
+
throw error;
20
20
+
}
21
21
+
},
13
22
staleTime: 3000,
14
23
refetchInterval: 20000,
15
24
refetchOnWindowFocus: true,
+2
-4
src/webapp/features/semble/components/sembleActions/GusetSembleActions.tsx
View file
Reviewed
···
5
5
import { notifications } from '@mantine/notifications';
6
6
import { TbPlugConnected } from 'react-icons/tb';
7
7
import { LinkButton } from '@/components/link/MantineLink';
8
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
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
52
-
href={loginUrl}
50
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
60
-
<LinkButton href={loginUrl}>Log in to add</LinkButton>
58
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
View file
Reviewed
···
1
1
import { Stack, Text } from '@mantine/core';
2
2
import { createServerSembleClient } from '@/services/server.apiClient';
3
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
7
+
await verifySessionOnServer({ redirectOnFail: true });
8
8
+
6
9
const client = await createServerSembleClient();
7
10
const profile = await client.getMyProfile();
8
11
+19
-44
src/webapp/hooks/useAuth.tsx
View file
Reviewed
···
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
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
23
-
interface LogoutOptions {
24
24
-
returnTo?: string;
25
25
-
}
26
26
-
27
22
interface AuthContextType {
28
23
user: GetProfileResponse | null;
29
24
isAuthenticated: boolean;
30
25
isLoading: boolean;
31
26
refreshAuth: () => Promise<void>;
32
32
-
logout: (options?: LogoutOptions) => Promise<void>;
27
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
42
-
const logout = useCallback(
43
43
-
async (options?: LogoutOptions) => {
37
37
+
const logout = useCallback(async () => {
38
38
+
if (ENABLE_AUTH_LOGGING) {
39
39
+
console.log('[useAuth] Initiating logout process');
40
40
+
}
41
41
+
42
42
+
// Reset PostHog user identity
43
43
+
if (shouldCaptureAnalytics()) {
44
44
+
posthog.reset();
44
45
if (ENABLE_AUTH_LOGGING) {
45
45
-
console.log('[useAuth] Initiating logout process');
46
46
+
console.log('[useAuth] PostHog user identity reset');
46
47
}
48
48
+
}
47
49
48
48
-
// Reset PostHog user identity
49
49
-
if (shouldCaptureAnalytics()) {
50
50
-
posthog.reset();
51
51
-
if (ENABLE_AUTH_LOGGING) {
52
52
-
console.log('[useAuth] PostHog user identity reset');
53
53
-
}
54
54
-
}
55
55
-
56
56
-
await ClientCookieAuthService.clearTokens();
57
57
-
queryClient.clear();
58
58
-
59
59
-
const safeReturnTo = sanitizeReturnTo(options?.returnTo);
60
60
-
if (safeReturnTo) {
61
61
-
router.push(`/login?returnTo=${encodeURIComponent(safeReturnTo)}`);
62
62
-
} else {
63
63
-
router.push('/login');
64
64
-
}
65
65
-
},
66
66
-
[queryClient, router],
67
67
-
);
50
50
+
await ClientCookieAuthService.clearTokens();
51
51
+
queryClient.clear();
52
52
+
router.push('/login');
53
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
77
-
retry: 2,
78
78
-
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
63
63
+
retry: false,
79
64
});
80
65
81
66
const refreshAuth = useCallback(async () => {
···
83
68
}, [query.refetch]);
84
69
85
70
useEffect(() => {
86
86
-
// query.data === null means /api/auth/me returned 401 (genuinely not
87
87
-
// authenticated). query.isError means a transient failure (500, network)
88
88
-
// after all retries — don't logout for that, the session may still be valid.
89
89
-
const isAuthPage =
90
90
-
pathname === '/login' || pathname === '/signup' || pathname === '/logout';
91
91
-
if (
92
92
-
query.data === null &&
93
93
-
!query.isLoading &&
94
94
-
pathname !== '/' &&
95
95
-
!isAuthPage
96
96
-
)
97
97
-
logout({ returnTo: pathname ?? undefined });
98
98
-
}, [query.data, query.isLoading, pathname, logout]);
71
71
+
// Handle other auth errors
72
72
+
if (query.isError && !query.isLoading && pathname !== '/') logout();
73
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
View file
Reviewed
···
1
1
import { GetProfileResponse } from '@/api-client/ApiClient';
2
2
import { cookies } from 'next/headers';
3
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
7
-
export const verifySessionOnServer = cache(async () => {
8
8
+
interface Options {
9
9
+
redirectOnFail?: boolean;
10
10
+
}
11
11
+
12
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
17
+
// tokens are missing
12
18
if (!accessToken || !refreshToken) {
19
19
+
if (options?.redirectOnFail) {
20
20
+
redirect('/login');
21
21
+
}
13
22
return null;
14
23
}
15
24
16
25
const res = await fetch(`${appUrl}/api/auth/me`, {
17
26
headers: {
18
18
-
Cookie: cookieStore.toString(),
27
27
+
Cookie: cookieStore.toString(), // forward user's cookies
19
28
},
20
29
});
21
30
22
31
if (!res.ok) {
32
32
+
if (options?.redirectOnFail) {
33
33
+
redirect('/login');
34
34
+
}
23
35
return null;
24
36
}
25
37
+5
-15
src/webapp/lib/auth/dal.ts
View file
Reviewed
···
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
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
13
-
}
14
14
-
15
15
-
function buildLoginUrlFromCurrentLocation(): string {
16
16
-
if (typeof window === 'undefined') return '/login';
17
17
-
const current = window.location.pathname + window.location.search;
18
18
-
const safe = sanitizeReturnTo(current);
19
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
42
-
if (response.status === 401) {
43
43
-
if (redirectOnFail && typeof window !== 'undefined') {
44
44
-
window.location.href = buildLoginUrlFromCurrentLocation();
45
45
-
}
46
46
-
return null;
34
34
+
if (redirectOnFail && typeof window !== 'undefined') {
35
35
+
// Redirect to login only if requested
36
36
+
window.location.href = '/login';
47
37
}
48
48
-
throw new Error(`Auth check failed: ${response.status}`);
38
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
70
-
window.location.href = buildLoginUrlFromCurrentLocation();
60
60
+
window.location.href = '/login';
71
61
}
72
62
};
-15
src/webapp/lib/auth/returnTo.ts
Reviewed
···
1
1
-
export function sanitizeReturnTo(
2
2
-
raw: string | string[] | undefined | null,
3
3
-
): string | null {
4
4
-
if (typeof raw !== 'string' || raw.length === 0) return null;
5
5
-
if (!raw.startsWith('/')) return null;
6
6
-
if (raw.startsWith('//') || raw.startsWith('/\\')) return null;
7
7
-
if (
8
8
-
raw === '/login' ||
9
9
-
raw.startsWith('/login?') ||
10
10
-
raw.startsWith('/login#')
11
11
-
) {
12
12
-
return null;
13
13
-
}
14
14
-
return raw;
15
15
-
}
-23
src/webapp/lib/auth/useLoginUrlWithReturnTo.ts
Reviewed
···
1
1
-
'use client';
2
2
-
3
3
-
import { usePathname } from 'next/navigation';
4
4
-
import { useEffect, useState } from 'react';
5
5
-
import { sanitizeReturnTo } from './returnTo';
6
6
-
7
7
-
export function useLoginUrlWithReturnTo(): string {
8
8
-
const pathname = usePathname();
9
9
-
const [url, setUrl] = useState('/login');
10
10
-
11
11
-
useEffect(() => {
12
12
-
if (typeof window === 'undefined') return;
13
13
-
const current = window.location.pathname + window.location.search;
14
14
-
if (!current || current === '/') {
15
15
-
setUrl('/login');
16
16
-
return;
17
17
-
}
18
18
-
const safe = sanitizeReturnTo(current);
19
19
-
setUrl(safe ? `/login?returnTo=${encodeURIComponent(safe)}` : '/login');
20
20
-
}, [pathname]);
21
21
-
22
22
-
return url;
23
23
-
}
-71
src/webapp/proxy.ts
Reviewed
···
1
1
-
import { NextResponse } from 'next/server';
2
2
-
import type { NextRequest } from 'next/server';
3
3
-
import { sanitizeReturnTo } from './lib/auth/returnTo';
4
4
-
5
5
-
const RETURN_TO_COOKIE = 'postLoginReturnTo';
6
6
-
7
7
-
const PROTECTED_PREFIXES = ['/home', '/notifications', '/settings'];
8
8
-
9
9
-
function isProtectedPath(pathname: string): boolean {
10
10
-
return PROTECTED_PREFIXES.some(
11
11
-
(p) => pathname === p || pathname.startsWith(`${p}/`),
12
12
-
);
13
13
-
}
14
14
-
15
15
-
function hasSessionCookies(request: NextRequest): boolean {
16
16
-
return (
17
17
-
!!request.cookies.get('accessToken')?.value &&
18
18
-
!!request.cookies.get('refreshToken')?.value
19
19
-
);
20
20
-
}
21
21
-
22
22
-
function safeDecode(raw: string): string | null {
23
23
-
try {
24
24
-
return decodeURIComponent(raw);
25
25
-
} catch {
26
26
-
return null;
27
27
-
}
28
28
-
}
29
29
-
30
30
-
export function proxy(request: NextRequest) {
31
31
-
const { pathname, search } = request.nextUrl;
32
32
-
33
33
-
// Consume the one-shot return-to cookie when the user lands on /home
34
34
-
// after the Bluesky OAuth round-trip. The backend callback hardcodes its
35
35
-
// redirect to /home, so this cookie is how the pre-OAuth destination
36
36
-
// survives the round-trip.
37
37
-
if (pathname === '/home' && hasSessionCookies(request)) {
38
38
-
const raw = request.cookies.get(RETURN_TO_COOKIE)?.value;
39
39
-
if (raw) {
40
40
-
const safe = sanitizeReturnTo(safeDecode(raw));
41
41
-
if (safe && safe !== '/home') {
42
42
-
const response = NextResponse.redirect(new URL(safe, request.url));
43
43
-
response.cookies.set(RETURN_TO_COOKIE, '', { path: '/', maxAge: 0 });
44
44
-
return response;
45
45
-
}
46
46
-
const response = NextResponse.next();
47
47
-
response.cookies.set(RETURN_TO_COOKIE, '', { path: '/', maxAge: 0 });
48
48
-
return response;
49
49
-
}
50
50
-
}
51
51
-
52
52
-
// Gate protected routes. Checking cookie presence here avoids
53
53
-
// rendering the page for unauthenticated users at all, which is the
54
54
-
// recommended Next.js pattern. Expired-but-present tokens pass through
55
55
-
// here and are handled by downstream data fetches.
56
56
-
if (isProtectedPath(pathname) && !hasSessionCookies(request)) {
57
57
-
const returnTo = `${pathname}${search}`;
58
58
-
const safe = sanitizeReturnTo(returnTo);
59
59
-
const loginUrl = new URL(
60
60
-
safe ? `/login?returnTo=${encodeURIComponent(safe)}` : '/login',
61
61
-
request.url,
62
62
-
);
63
63
-
return NextResponse.redirect(loginUrl);
64
64
-
}
65
65
-
66
66
-
return NextResponse.next();
67
67
-
}
68
68
-
69
69
-
export const config = {
70
70
-
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/).*)'],
71
71
-
};