Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ go run ./cmd/notifications-service
Run the SPA:

```bash
cd business-frontend
cd frontend
npm install
npm run dev
```
Expand Down
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>business-frontend</title>
<title>Share Bite</title>
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "business-frontend",
"name": "share-bite-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
Expand Down
97 changes: 80 additions & 17 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,17 +330,22 @@ export const apiClient = {
}));
},

createPost: async (data: CreatePostInput) => {
createPost: async (data: CreatePostInput, token?: string) => {
const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined;
const createFormData = new FormData();
createFormData.append("venueId", data.venueId.toString());
createFormData.append("text", data.text);
createFormData.append("rating", data.rating.toString());
if (data.images?.length) {
data.images.forEach((img) => createFormData.append("images", img));
}
if (data.invitedCustomerIds?.length) {
data.invitedCustomerIds.forEach((id) => createFormData.append("invitedCustomerIds", id));
}
const createRes = await guestApi.post<{ post: RawPost }>(
"/posts/",
createFormData
createFormData,
config
);
const newPostId = createRes.data.post.id;
const patchFormData = new FormData();
Expand All @@ -351,7 +356,8 @@ export const apiClient = {
try {
const patchRes = await guestApi.patch<{ post: RawPost }>(
`/posts/${newPostId}`,
patchFormData
patchFormData,
config
);
return mapRawPostToPost(patchRes.data.post);
} catch {
Expand Down Expand Up @@ -404,8 +410,9 @@ export const apiClient = {
await guestApi.delete(`/posts/${postId}`);
},

likePost: async (postId: string) => {
await guestApi.post(`/posts/${postId}/like`);
likePost: async (postId: string, token?: string) => {
const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined;
await guestApi.post(`/posts/${postId}/like`, {}, config);
},

unlikePost: async (postId: string) => {
Expand All @@ -432,20 +439,25 @@ export const apiClient = {
return mapCustomer(res.data.customer);
},

createCustomer: async (data: {
email: string;
userName: string;
firstName: string;
lastName: string;
bio?: string;
}) => {
const res = await guestApi.post<{ customerId: string }>("customers/", data);
createCustomer: async (
data: {
email: string;
userName: string;
firstName: string;
lastName: string;
bio?: string;
},
token?: string
) => {
const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined;
const res = await guestApi.post<{ customerId: string }>("customers/", data, config);
markGuestHasCustomer();
return res.data;
},

getCurrentCustomer: async () => {
const res = await guestApi.get<{ customer: RawCustomerResponse }>("customers/");
getCurrentCustomer: async (token?: string) => {
const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined;
const res = await guestApi.get<{ customer: RawCustomerResponse }>("customers/", config);
markGuestHasCustomer();
return mapCustomer(res.data.customer);
},
Expand Down Expand Up @@ -503,10 +515,12 @@ export const apiClient = {
return res.data;
},

createComment: async (postId: string | number, text: string) => {
createComment: async (postId: string | number, text: string, token?: string) => {
const config = token ? { headers: { Authorization: `Bearer ${token}` } } : undefined;
const res = await guestApi.post<{ comment: CommentResponse }>(
`/posts/${postId}/comments/`,
{ text }
{ text },
config
);
return res.data.comment;
},
Expand Down Expand Up @@ -656,4 +670,53 @@ export const apiClient = {
) => {
await guestApi.post(`/collections/${collectionId}/invitations`, { email });
},

followUser: async (customerId: string | number, token: string) => {
await guestApi.post(`/customers/id/${customerId}/follow`, {}, {
headers: { Authorization: `Bearer ${token}` },
});
},

unfollowUser: async (customerId: string | number, token: string) => {
await guestApi.delete(`/customers/id/${customerId}/follow`, {
headers: { Authorization: `Bearer ${token}` },
});
},

getPostInvitations: async (token: string) => {
const res = await guestApi.get<{ invitations: any[] }>("/posts/invitations", {
headers: { Authorization: `Bearer ${token}` },
});
return res.data;
},

acceptPostInvitation: async (id: number | string, token: string) => {
await guestApi.post(`/posts/invitations/${id}/accept`, {}, {
headers: { Authorization: `Bearer ${token}` },
});
},

labRegister: async (data: RegisterRequest) => {
const res = await authApi.post<AuthResponse>("/register", data);
return res.data;
},

labLogin: async (data: LoginRequest) => {
const res = await authApi.post<AuthResponse>("/login", data);
return res.data;
},

getNotificationHistory: async (limit: number, offset: number, token: string) => {
const res = await apiRoot.get(`/notifications/history`, {
params: { limit, offset },
headers: { Authorization: `Bearer ${token}` },
});
return res.data;
},

markNotificationsRead: async (notificationIDs: string[], token: string) => {
await apiRoot.post(`/notifications/mark-read`, { notificationIDs }, {
headers: { Authorization: `Bearer ${token}` },
});
},
};
29 changes: 29 additions & 0 deletions frontend/src/api/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@ export async function markNotificationsRead(
}
}

export async function fetchNotificationPreferences(
token: string
): Promise<Record<string, boolean>> {
const response = await fetch(`${API_BASE_URL}/preferences`, {
headers: authHeaders(token),
});

if (!response.ok) {
throw new Error(`Failed to load notification preferences: ${response.status}`);
}

return response.json();
}

export async function updateNotificationPreferences(
token: string,
preferences: Record<string, boolean>
): Promise<void> {
const response = await fetch(`${API_BASE_URL}/preferences`, {
method: "PUT",
headers: authHeaders(token),
body: JSON.stringify(preferences),
});

if (!response.ok) {
throw new Error(`Failed to update notification preferences: ${response.status}`);
}
}

export function buildNotificationsStreamUrl(token: string) {
return `${API_BASE_URL}/stream?access_token=${encodeURIComponent(token)}`;
}
35 changes: 35 additions & 0 deletions frontend/src/components/Notifications/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, CheckCheck } from "lucide-react";
Expand All @@ -12,6 +13,7 @@ import {
} from "@/components/ui/popover";
import { pageLinkAccent } from "@/components/layout/pageStyles";
import { cn } from "@/lib/utils";
import { playNotificationSound } from "@/utils/audio";

type NotificationBellProps = {
variant?: "default" | "compact";
Expand All @@ -30,6 +32,39 @@ export function NotificationBell({ variant = "default" }: NotificationBellProps)
enabled: !!token,
});

const prevNewestIdRef = useRef<string | null>(null);
const isInitialMountRef = useRef(true);

useEffect(() => {
if (!token || notifications.length === 0) {
prevNewestIdRef.current = null;
isInitialMountRef.current = true;
return;
}

const newestNotification = notifications[0];
const newestId = newestNotification.id;

if (isInitialMountRef.current) {
prevNewestIdRef.current = newestId;
isInitialMountRef.current = false;
return;
}

if (newestId !== prevNewestIdRef.current) {
prevNewestIdRef.current = newestId;

if (!newestNotification.read) {
const soundEnabled = localStorage.getItem("notification_sound_enabled") !== "false";
if (soundEnabled) {
const volStr = localStorage.getItem("notification_sound_volume");
const volume = volStr ? parseFloat(volStr) / 100 : 0.5;
playNotificationSound(volume);
}
}
}
}, [notifications, token]);

const markRead = useMutation({
mutationFn: (ids: string[]) => markNotificationsRead(token!, ids),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notifications"] }),
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/hooks/useRealtimeNotifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'react';

export interface LabNotification {
id: string;
type: string;
entityID: string;
metadata?: any;
isRead: boolean;
createdAt: string;
readAt?: string;
}

export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';

export function useRealtimeNotifications(token: string | null) {
const [notifications, setNotifications] = useState<LabNotification[]>([]);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);

useEffect(() => {
if (!token) {
setStatus('disconnected');
setNotifications([]);
return;
}

const connect = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}

setStatus('connecting');
const url = `/api/notifications/stream?access_token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
Comment on lines +34 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and examine the file
fd -t f "useRealtimeNotifications" --type f | head -5

Repository: ua-academy-projects/share-bite

Length of output: 305


🏁 Script executed:

# Get the file structure and read the relevant lines
cat -n frontend/src/hooks/useRealtimeNotifications.tsx | sed -n '30,60p'

Repository: ua-academy-projects/share-bite

Length of output: 1603


🏁 Script executed:

# Search for the /api/notifications/stream endpoint in backend
fd -t f "\.ts$" "\.tsx$" "\.js$" | xargs rg "notifications/stream" -l 2>/dev/null

Repository: ua-academy-projects/share-bite

Length of output: 399


🏁 Script executed:

# Check for backend auth patterns (cookies, auth middleware)
rg -t js -t ts "withCredentials|credentials.*include|cookie" --max-count=3 | head -20

Repository: ua-academy-projects/share-bite

Length of output: 168


🏁 Script executed:

# Read the backend notifications endpoint implementation
cat -n ui/src/api/business/notifications.ts | head -100

Repository: ua-academy-projects/share-bite

Length of output: 1845


🏁 Script executed:

# Search for API route definitions related to notifications/stream
rg "notifications.*stream" -A 10 -B 2

Repository: ua-academy-projects/share-bite

Length of output: 168


🏁 Script executed:

# Check for auth middleware patterns
rg "passport|auth.*middleware|withAuth|guards.*auth" -B 2 -A 5 | head -50

Repository: ua-academy-projects/share-bite

Length of output: 168


🏁 Script executed:

# Search for the stream endpoint implementation in backend
rg "notifications.*stream" -t ts -t js -B 5 -A 15

Repository: ua-academy-projects/share-bite

Length of output: 168


🏁 Script executed:

# Look for EventSource or SSE handling in backend
rg "EventSource|\.on\(|response\.write|stream.*auth" -t ts -t js | head -30

Repository: ua-academy-projects/share-bite

Length of output: 168


🏁 Script executed:

# Search for backend route definitions
fd -type f -name "*route*" -o -name "*controller*" | xargs rg "notifications" -l 2>/dev/null

Repository: ua-academy-projects/share-bite

Length of output: 1459


🏁 Script executed:

# Read the backend notification handler
cat -n internal/business/handler/notification/handler.go | head -150

Repository: ua-academy-projects/share-bite

Length of output: 2560


🏁 Script executed:

# Also check the other handler
cat -n internal/notification/handler/handler.go | head -150

Repository: ua-academy-projects/share-bite

Length of output: 5038


🏁 Script executed:

# Search for how the token is validated in the stream endpoint
rg "access_token|stream" internal/ -t go -B 3 -A 8 | head -80

Repository: ua-academy-projects/share-bite

Length of output: 6475


🏁 Script executed:

# Check what authMiddleware expects - look for auth implementation
rg "authMiddleware|middleware.*auth" internal/ cmd/ -B 2 -A 10 | head -100

Repository: ua-academy-projects/share-bite

Length of output: 6569


🏁 Script executed:

# Look for cookie/session configuration
rg "cookie|session|SetCookie" internal/ cmd/ -B 2 -A 5 | head -80

Repository: ua-academy-projects/share-bite

Length of output: 6144


🏁 Script executed:

# Check how the middleware validates tokens
rg "Bearer|Authorization|GetUserID" internal/middleware -B 3 -A 8

Repository: ua-academy-projects/share-bite

Length of output: 2647


Remove bearer token from SSE URL and logging; implement cookie-based or session-ticket authentication.

Line 34 embeds the access token in the query string, and lines 38/52 log token fragments. This increases token exposure through browser logs, proxy logs, and server access logs. While the backend's OptionalAuth middleware currently accepts query parameter tokens as a fallback, this pattern should be phased out in favor of more secure authentication.

Implementation note: The browser's EventSource API cannot send custom headers, so the suggested approach requires backend support for cookie-based authentication. The backend would need to:

  1. Set a secure, HttpOnly, SameSite cookie on the initial request
  2. Remove the fallback query-parameter token acceptance from the middleware
  3. Accept requests with credentials when using new EventSource(url, { withCredentials: true })

Alternatively, consider using short-lived session tickets or a WebSocket-based approach.

Also applies to: 38-39, 52, and the buildNotificationsStreamUrl function in ui/src/api/business/notifications.ts

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/useRealtimeNotifications.tsx` around lines 34 - 35, Remove
the access token from the query string in the EventSource URL construction (line
34 where the url is built with the token parameter) and instead pass the
withCredentials option to the EventSource constructor to enable cookie-based
authentication. Remove any logging that exposes token fragments from lines 38
and 52. Apply the same changes to the buildNotificationsStreamUrl function in
ui/src/api/business/notifications.ts. The backend would need to support
cookie-based or session-ticket authentication instead of accepting tokens in
query parameters.


es.onopen = () => {
console.log("[SSE] Connection established");
setStatus('connected');
};

es.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as LabNotification;
setNotifications((prev) => [data, ...prev].slice(0, 100));
} catch (err) {
console.error("[SSE] Failed to parse event data", err);
}
};

es.onerror = () => {
console.error("[SSE] Connection error, retrying in 5s");
setStatus('connecting');
es.close();

if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = window.setTimeout(connect, 5000);
};

eventSourceRef.current = es;
};

connect();

return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, [token]);

const clearNotifications = () => setNotifications([]);

return {
notifications,
setNotifications,
status,
clearNotifications
};
}
Loading
Loading