src
webapp
app
components
features
cards
collections
notes
profile
hooks
lib
···
14
14
Loader,
15
15
Badge,
16
16
} from '@mantine/core';
17
17
-
import { Suspense, useEffect, useState } from 'react';
17
17
+
import { Suspense, useEffect } from 'react';
18
18
import { IoMdHelpCircleOutline } from 'react-icons/io';
19
19
import SembleLogo from '@/assets/semble-logo.svg';
20
20
import { useAuth } from '@/hooks/useAuth';
···
22
22
import Link from 'next/link';
23
23
24
24
function InnerPage() {
25
25
-
const { isAuthenticated, isLoading } = useAuth();
26
26
-
const [isRedirecting, setIsRedirecting] = useState(false);
25
25
+
const { isAuthenticated, isLoading, refreshAuth } = useAuth();
27
26
const router = useRouter();
28
27
const searchParams = useSearchParams();
29
28
const isExtensionLogin = searchParams.get('extension-login') === 'true';
30
29
31
30
useEffect(() => {
32
32
-
let timeoutId: NodeJS.Timeout;
33
33
-
34
31
if (isAuthenticated && !isExtensionLogin) {
35
35
-
setIsRedirecting(true);
36
36
-
37
37
-
// redirect after 1 second
38
38
-
timeoutId = setTimeout(() => {
39
39
-
router.push('/home');
40
40
-
}, 1000);
32
32
+
refreshAuth();
33
33
+
router.push('/home');
41
34
}
42
42
-
43
43
-
// clean up
44
44
-
return () => {
45
45
-
if (timeoutId) {
46
46
-
clearTimeout(timeoutId);
47
47
-
}
48
48
-
};
49
35
}, [isAuthenticated, router, isExtensionLogin]);
50
36
51
51
-
if (isLoading) {
37
37
+
if (isAuthenticated) {
52
38
return (
53
39
<Stack align="center">
54
54
-
<Loader type="dots" />
55
55
-
</Stack>
56
56
-
);
57
57
-
}
58
58
-
59
59
-
if (isRedirecting) {
60
60
-
return (
61
61
-
<Stack align="center">
62
62
-
<Text fw={500} fz={'xl'}>
63
63
-
Already logged in, redirecting you to library
64
64
-
</Text>
65
40
<Loader type="dots" />
66
41
</Stack>
67
42
);
···
1
1
+
'use client';
2
2
+
3
3
+
import {
4
4
+
BackgroundImage,
5
5
+
Center,
6
6
+
Stack,
7
7
+
Image,
8
8
+
Badge,
9
9
+
Text,
10
10
+
Group,
11
11
+
Button,
12
12
+
Container,
13
13
+
} from '@mantine/core';
14
14
+
import SembleLogo from '@/assets/semble-logo.svg';
15
15
+
import BG from '@/assets/semble-bg.webp';
16
16
+
import Link from 'next/link';
17
17
+
import { BiRightArrowAlt } from 'react-icons/bi';
18
18
+
19
19
+
export default function Error() {
20
20
+
return (
21
21
+
<BackgroundImage
22
22
+
src={BG.src}
23
23
+
h={'100svh'}
24
24
+
pos={'fixed'}
25
25
+
top={0}
26
26
+
left={0}
27
27
+
style={{ zIndex: 102 }}
28
28
+
>
29
29
+
<Center h={'100svh'} py={{ base: '2rem', xs: '5rem' }}>
30
30
+
<Container size={'xl'} p={'md'} my={'auto'}>
31
31
+
<Stack>
32
32
+
<Stack align="center" gap={'xs'}>
33
33
+
<Image
34
34
+
src={SembleLogo.src}
35
35
+
alt="Semble logo"
36
36
+
w={48}
37
37
+
h={64.5}
38
38
+
mx={'auto'}
39
39
+
/>
40
40
+
<Badge size="sm">Alpha</Badge>
41
41
+
</Stack>
42
42
+
43
43
+
<Stack>
44
44
+
<Text fz={'h1'} fw={600} ta={'center'}>
45
45
+
A social knowledge network for researchers
46
46
+
</Text>
47
47
+
<Text fz={'h3'} fw={600} c={'#1F6144'} ta={'center'}>
48
48
+
Follow your peers’ research trails. Surface and discover new
49
49
+
connections. Built on ATProto so you own your data.
50
50
+
</Text>
51
51
+
</Stack>
52
52
+
53
53
+
<Group justify="center" gap="md" mt={'lg'}>
54
54
+
<Button component={Link} href="/signup" size="lg">
55
55
+
Sign up
56
56
+
</Button>
57
57
+
58
58
+
<Button
59
59
+
component={Link}
60
60
+
href="/login"
61
61
+
size="lg"
62
62
+
color="dark"
63
63
+
rightSection={<BiRightArrowAlt size={22} />}
64
64
+
>
65
65
+
Log in
66
66
+
</Button>
67
67
+
</Group>
68
68
+
</Stack>
69
69
+
</Container>
70
70
+
</Center>
71
71
+
</BackgroundImage>
72
72
+
);
73
73
+
}
···
1
1
import HomeContainer from '@/features/home/containers/homeContainer/HomeContainer';
2
2
+
import { verifySessionOnServer } from '@/lib/auth/dal.server';
3
3
+
import { redirect } from 'next/navigation';
2
4
3
3
-
export default function Page() {
5
5
+
export default async function Page() {
6
6
+
const session = await verifySessionOnServer();
7
7
+
if (!session) redirect('/login');
8
8
+
4
9
return <HomeContainer />;
5
10
}
···
1
1
-
'use client';
2
2
-
3
3
-
import { useEffect } from 'react';
4
4
-
import { useRouter } from 'next/navigation';
5
5
-
import { useAuth } from '@/hooks/useAuth';
6
1
import Dashboard from '@/components/navigation/dashboard/Dashboard';
7
2
8
3
interface Props {
9
4
children: React.ReactNode;
10
5
}
11
6
export default function Layout(props: Props) {
12
12
-
const router = useRouter();
13
13
-
const { isAuthenticated, isLoading } = useAuth();
14
14
-
15
15
-
useEffect(() => {
16
16
-
if (!isLoading && !isAuthenticated) {
17
17
-
router.push('/login');
18
18
-
}
19
19
-
}, [isAuthenticated, isLoading, router]);
20
20
-
21
21
-
if (!isAuthenticated) {
22
22
-
return null; // Redirecting
23
23
-
}
24
24
-
25
7
return <Dashboard>{props.children}</Dashboard>;
26
8
}
···
1
1
import { NextRequest, NextResponse } from 'next/server';
2
2
+
import type { GetProfileResponse } from '@/api-client/ApiClient';
2
3
import { cookies } from 'next/headers';
3
4
import { isTokenExpiringSoon } from '@/lib/auth/token';
4
5
6
6
+
const backendUrl = process.env.API_BASE_URL || 'http://127.0.0.1:3000';
7
7
+
8
8
+
type AuthResult = {
9
9
+
isAuth: boolean;
10
10
+
user?: GetProfileResponse;
11
11
+
};
12
12
+
5
13
export async function GET(request: NextRequest) {
6
14
try {
7
15
const cookieStore = await cookies();
···
10
18
11
19
// No tokens at all - not authenticated
12
20
if (!accessToken && !refreshToken) {
13
13
-
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
21
21
+
return NextResponse.json<AuthResult>({ isAuth: false }, { status: 401 });
14
22
}
15
23
16
24
// Check if accessToken is expired/missing or expiring soon (< 5 min)
17
25
if ((!accessToken || isTokenExpiringSoon(accessToken, 5)) && refreshToken) {
18
26
try {
19
27
// Proxy the refresh request completely to backend
20
20
-
const backendUrl =
21
21
-
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000';
22
28
const refreshResponse = await fetch(
23
29
`${backendUrl}/api/users/oauth/refresh`,
24
30
{
···
32
38
);
33
39
34
40
if (!refreshResponse.ok) {
35
35
-
// Refresh failed - clear invalid tokens
36
36
-
const response = new NextResponse(
37
37
-
JSON.stringify({ error: 'Authentication failed' }),
41
41
+
// Refresh failed — clear tokens and mark as unauthenticated
42
42
+
const response = NextResponse.json<AuthResult>(
43
43
+
{ isAuth: false },
38
44
{ status: 401 },
39
45
);
40
46
response.cookies.delete('accessToken');
41
47
response.cookies.delete('refreshToken');
48
48
+
42
49
return response;
43
50
}
44
51
···
56
63
});
57
64
58
65
if (!profileResponse.ok) {
59
59
-
return NextResponse.json(
60
60
-
{ error: 'Failed to fetch profile' },
61
61
-
{ status: profileResponse.status },
66
66
+
return NextResponse.json<AuthResult>(
67
67
+
{ isAuth: false },
68
68
+
{ status: 401 },
62
69
);
63
70
}
64
71
65
72
const user = await profileResponse.json();
66
73
67
74
// Return user profile with backend's Set-Cookie headers
68
68
-
return new Response(JSON.stringify({ user }), {
75
75
+
const response = new Response(JSON.stringify({ isAuth: true, user }), {
69
76
status: 200,
70
77
headers: {
71
78
'Content-Type': 'application/json',
72
79
'Set-Cookie': refreshResponse.headers.get('set-cookie') || '',
73
80
},
74
81
});
82
82
+
return response;
75
83
} catch (error) {
76
84
console.error('Token refresh error:', error);
77
77
-
return NextResponse.json(
78
78
-
{ error: 'Authentication failed' },
85
85
+
return NextResponse.json<AuthResult>(
86
86
+
{ isAuth: false },
79
87
{ status: 500 },
80
88
);
81
89
}
···
94
102
});
95
103
96
104
if (!profileResponse.ok) {
97
97
-
return NextResponse.json(
98
98
-
{ error: 'Failed to fetch profile' },
105
105
+
return NextResponse.json<AuthResult>(
106
106
+
{ isAuth: false },
99
107
{ status: profileResponse.status },
100
108
);
101
109
}
102
110
103
111
const user = await profileResponse.json();
104
104
-
return NextResponse.json({ user });
112
112
+
return NextResponse.json<AuthResult>({ isAuth: true, user });
105
113
} catch (error) {
106
114
console.error('Profile fetch error:', error);
107
107
-
return NextResponse.json(
108
108
-
{ error: 'Failed to fetch profile' },
109
109
-
{ status: 500 },
110
110
-
);
115
115
+
return NextResponse.json<AuthResult>({ isAuth: false }, { status: 500 });
111
116
}
112
117
} catch (error) {
113
118
console.error('Auth me error:', error);
114
114
-
return NextResponse.json(
115
115
-
{ error: 'Internal server error' },
116
116
-
{ status: 500 },
117
117
-
);
119
119
+
return NextResponse.json<AuthResult>({ isAuth: false }, { status: 500 });
118
120
}
119
121
}
···
1
1
+
'use client';
2
2
+
1
3
import AppLayout from '../appLayout/AppLayout';
4
4
+
import GuestAppLayout from '../guestAppLayout/GuestAppLayout';
5
5
+
import { useAuth } from '@/hooks/useAuth';
2
6
3
7
interface Props {
4
8
children: React.ReactNode;
5
9
}
6
10
7
11
export default function Dashboard(props: Props) {
12
12
+
const { isAuthenticated, isLoading } = useAuth();
13
13
+
14
14
+
if (isLoading) return null;
15
15
+
16
16
+
if (!isAuthenticated)
17
17
+
return <GuestAppLayout>{props.children}</GuestAppLayout>;
18
18
+
8
19
return <AppLayout>{props.children}</AppLayout>;
9
20
}
···
1
1
+
import { verifySessionOnClient } from '@/lib/auth/dal';
1
2
import { createSembleClient } from '@/services/apiClient';
2
3
import { cache } from 'react';
3
4
···
14
15
});
15
16
16
17
export const getCardFromMyLibrary = cache(async (url: string) => {
17
17
-
// await verifySession();
18
18
+
const session = await verifySessionOnClient();
19
19
+
if (!session) throw new Error('No session found');
18
20
const client = createSembleClient();
19
21
const response = await client.getUrlStatusForMyLibrary({ url: url });
20
22
···
22
24
});
23
25
24
26
export const getMyUrlCards = cache(async (params?: PageParams) => {
25
25
-
// await verifySession();
27
27
+
const session = await verifySessionOnClient();
28
28
+
if (!session) throw new Error('No session found');
26
29
const client = createSembleClient();
27
30
const response = await client.getMyUrlCards({
28
31
page: params?.page,
···
37
40
url: string,
38
41
{ note, collectionIds }: { note?: string; collectionIds?: string[] },
39
42
) => {
40
40
-
// await verifySession();
43
43
+
const session = await verifySessionOnClient();
44
44
+
if (!session) throw new Error('No session found');
41
45
const client = createSembleClient();
42
46
const response = await client.addUrlToLibrary({
43
47
url: url,
···
77
81
cardId: string;
78
82
collectionIds: string[];
79
83
}) => {
80
80
-
// await verifySession();
84
84
+
const session = await verifySessionOnClient();
85
85
+
if (!session) throw new Error('No session found');
81
86
const client = createSembleClient();
82
87
const response = await client.removeCardFromCollection({
83
88
cardId,
···
89
94
);
90
95
91
96
export const removeCardFromLibrary = cache(async (cardId: string) => {
92
92
-
// await verifySession();
97
97
+
const session = await verifySessionOnClient();
98
98
+
if (!session) throw new Error('No session found');
93
99
const client = createSembleClient();
94
100
const response = await client.removeCardFromLibrary({ cardId });
95
101
···
97
103
});
98
104
99
105
export const getLibrariesForCard = cache(async (cardId: string) => {
100
100
-
// await verifySession();
106
106
+
const session = await verifySessionOnClient();
107
107
+
if (!session) throw new Error('No session found');
101
108
const client = createSembleClient();
102
109
const response = await client.getLibrariesForCard(cardId);
103
110
···
1
1
+
import { verifySessionOnClient } from '@/lib/auth/dal';
1
2
import { createSembleClient } from '@/services/apiClient';
2
3
import { cache } from 'react';
3
4
···
40
41
41
42
export const getMyCollections = cache(
42
43
async (params?: PageParams & SearchParams) => {
43
43
-
// await verifySession();
44
44
-
44
44
+
const session = await verifySessionOnClient();
45
45
+
if (!session) throw new Error('No session found');
45
46
const client = createSembleClient();
46
47
const response = await client.getMyCollections({
47
48
page: params?.page,
···
57
58
58
59
export const createCollection = cache(
59
60
async (newCollection: { name: string; description: string }) => {
60
60
-
// await verifySession();
61
61
+
const session = await verifySessionOnClient();
62
62
+
if (!session) throw new Error('No session found');
61
63
const client = createSembleClient();
62
64
const response = await client.createCollection(newCollection);
63
65
···
66
68
);
67
69
68
70
export const deleteCollection = cache(async (id: string) => {
69
69
-
// await verifySession();
71
71
+
const session = await verifySessionOnClient();
72
72
+
if (!session) throw new Error('No session found');
70
73
const client = createSembleClient();
71
74
const response = await client.deleteCollection({ collectionId: id });
72
75
···
80
83
name: string;
81
84
description?: string;
82
85
}) => {
83
83
-
// await verifySession();
86
86
+
const session = await verifySessionOnClient();
87
87
+
if (!session) throw new Error('No session found');
84
88
const client = createSembleClient();
85
89
const response = await client.updateCollection(collection);
86
90
···
1
1
+
import { verifySessionOnClient } from '@/lib/auth/dal';
1
2
import { createSembleClient } from '@/services/apiClient';
2
3
import { cache } from 'react';
3
4
···
21
22
22
23
export const updateNoteCard = cache(
23
24
async (note: { cardId: string; note: string }) => {
24
24
-
// await verifySession();
25
25
+
const session = await verifySessionOnClient();
26
26
+
if (!session) throw new Error('No session found');
25
27
const client = createSembleClient();
26
28
const response = await client.updateNoteCard(note);
27
29
···
1
1
+
import { verifySessionOnClient } from '@/lib/auth/dal';
1
2
import { createSembleClient } from '@/services/apiClient';
2
3
import { cache } from 'react';
3
4
···
11
12
});
12
13
13
14
export const getMyProfile = cache(async () => {
14
14
-
// await verifySession();
15
15
+
const session = await verifySessionOnClient();
16
16
+
if (!session) throw new Error('No session found');
15
17
const client = createSembleClient();
16
18
const response = await client.getMyProfile();
17
19
···
5
5
import { useRouter } from 'next/navigation';
6
6
import type { GetProfileResponse } from '@/api-client/ApiClient';
7
7
import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client';
8
8
-
9
9
-
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://127.0.0.1:4000';
8
8
+
import { verifySessionOnClient } from '@/lib/auth/dal';
10
9
11
10
interface AuthContextType {
12
11
user: GetProfileResponse | null;
···
28
27
29
28
const logout = async () => {
30
29
await ClientCookieAuthService.clearTokens();
31
31
-
queryClient.removeQueries({ queryKey: ['authenticated user'] });
30
30
+
queryClient.clear();
32
31
router.push('/login');
33
32
};
34
33
35
34
const query = useQuery<GetProfileResponse | null>({
36
35
queryKey: ['authenticated user'],
37
36
queryFn: async () => {
38
38
-
const response = await fetch(`${appUrl}/api/auth/me`, {
39
39
-
method: 'GET',
40
40
-
credentials: 'include', // HttpOnly cookies sent automatically
41
41
-
});
42
42
-
// unauthenticated
43
43
-
if (!response.ok) {
44
44
-
throw new Error('Not authenticated');
45
45
-
}
46
46
-
47
47
-
const data = await response.json();
48
48
-
49
49
-
return data.user as GetProfileResponse;
37
37
+
const session = await verifySessionOnClient();
38
38
+
return session;
50
39
},
51
40
staleTime: 5 * 60 * 1000, // cache for 5 minutes
52
41
refetchOnWindowFocus: false,
···
1
1
+
import { GetProfileResponse } from '@/api-client/ApiClient';
2
2
+
import { cookies } from 'next/headers';
3
3
+
import { cache } from 'react';
4
4
+
5
5
+
const appUrl = process.env.APP_URL || 'http://127.0.0.1:4000';
6
6
+
7
7
+
export const verifySessionOnServer = cache(async () => {
8
8
+
const cookieStore = await cookies();
9
9
+
const res = await fetch(`${appUrl}/api/auth/me`, {
10
10
+
headers: {
11
11
+
Cookie: cookieStore.toString(), // forward user's cookies
12
12
+
},
13
13
+
});
14
14
+
15
15
+
if (!res.ok) return null;
16
16
+
17
17
+
const { user }: { user: GetProfileResponse } = await res.json();
18
18
+
19
19
+
return user;
20
20
+
});
···
1
1
import type { GetProfileResponse } from '@/api-client/ApiClient';
2
2
-
import { ClientCookieAuthService } from '@/services/auth';
3
3
-
import { redirect } from 'next/navigation';
4
2
import { cache } from 'react';
5
3
6
6
-
const appUrl = process.env.APP_URL || 'http://127.0.0.1:4000';
4
4
+
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://127.0.0.1:4000';
7
5
8
8
-
export const verifySession = cache(
6
6
+
export const verifySessionOnClient = cache(
9
7
async (): Promise<GetProfileResponse | null> => {
10
8
const response = await fetch(`${appUrl}/api/auth/me`, {
11
9
method: 'GET',
···
13
11
});
14
12
15
13
if (!response.ok) {
16
16
-
await ClientCookieAuthService.clearTokens();
17
17
-
redirect('/login');
14
14
+
return null;
18
15
}
19
16
20
20
-
const data = await response.json();
17
17
+
const { user }: { user: GetProfileResponse } = await response.json();
21
18
22
22
-
return data.user as GetProfileResponse;
19
19
+
return user;
23
20
},
24
21
);
···
1
1
export const isTokenExpiringSoon = (
2
2
token: string | null | undefined,
3
3
-
bufferMinutes: number = 5,
3
3
+
bufferMinutes: number = 5, // 5 minutes
4
4
): boolean => {
5
5
if (!token) return true;
6
6