A social knowledge tool for researchers built on ATProto

Merge branch 'development' into fix/mock-persistence-config

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