tangled
alpha
login
or
join now
cosmik.network
/
semble
43
fork
atom
A social knowledge tool for researchers built on ATProto
43
fork
atom
overview
issues
13
pulls
pipelines
feat: public routes
Pouria Delfanazari
4 months ago
362372b8
9d1c118d
+178
-111
15 changed files
expand all
collapse all
unified
split
src
webapp
app
(auth)
login
page.tsx
(dashboard)
error.tsx
home
page.tsx
layout.tsx
api
auth
me
route.ts
components
navigation
dashboard
Dashboard.tsx
features
cards
lib
dal.ts
collections
lib
dal.ts
notes
lib
dal.ts
profile
components
profileMenu
ProfileMenu.tsx
lib
dal.ts
hooks
useAuth.tsx
lib
auth
dal.server.ts
dal.ts
token.ts
+5
-30
src/webapp/app/(auth)/login/page.tsx
···
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
);
+73
src/webapp/app/(dashboard)/error.tsx
···
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
+
}
+6
-1
src/webapp/app/(dashboard)/home/page.tsx
···
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
}
-18
src/webapp/app/(dashboard)/layout.tsx
···
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
}
+25
-23
src/webapp/app/api/auth/me/route.ts
···
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
}
+11
src/webapp/components/navigation/dashboard/Dashboard.tsx
···
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
}
+13
-6
src/webapp/features/cards/lib/dal.ts
···
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
+9
-5
src/webapp/features/collections/lib/dal.ts
···
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
+3
-1
src/webapp/features/notes/lib/dal.ts
···
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
-2
src/webapp/features/profile/components/profileMenu/ProfileMenu.tsx
···
4
4
Group,
5
5
Alert,
6
6
Menu,
7
7
-
Stack,
8
7
Image,
9
9
-
Text,
10
8
Button,
11
9
} from '@mantine/core';
12
10
import useMyProfile from '../../lib/queries/useMyProfile';
+3
-1
src/webapp/features/profile/lib/dal.ts
···
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
+4
-15
src/webapp/hooks/useAuth.tsx
···
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,
+20
src/webapp/lib/auth/dal.server.ts
···
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
+
});
+5
-8
src/webapp/lib/auth/dal.ts
···
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
src/webapp/lib/auth/token.ts
···
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