aboutsummaryrefslogtreecommitdiffstats
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/App.tsx5
-rw-r--r--frontend/src/components/FeedItem.tsx4
-rw-r--r--frontend/src/components/FeedItems.test.tsx10
-rw-r--r--frontend/src/components/FeedItems.tsx7
-rw-r--r--frontend/src/components/FeedList.tsx5
-rw-r--r--frontend/src/components/Login.tsx4
-rw-r--r--frontend/src/components/Settings.tsx7
-rw-r--r--frontend/src/components/TagView.test.tsx4
-rw-r--r--frontend/src/utils.ts31
9 files changed, 58 insertions, 19 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4835cd3..7943f60 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './components/Login';
import './App.css';
+import { apiFetch } from './utils';
// Protected Route wrapper
function RequireAuth({ children }: { children: React.ReactElement }) {
@@ -9,7 +10,7 @@ function RequireAuth({ children }: { children: React.ReactElement }) {
const location = useLocation();
useEffect(() => {
- fetch('/api/auth')
+ apiFetch('/api/auth')
.then((res) => {
if (res.ok) {
setAuth(true);
@@ -70,7 +71,7 @@ function Dashboard({ theme, setTheme }: { theme: string; setTheme: (t: string) =
<button
onClick={() => {
- fetch('/api/logout', { method: 'POST' }).then(
+ apiFetch('/api/logout', { method: 'POST' }).then(
() => (window.location.href = '/v2/login')
);
}}
diff --git a/frontend/src/components/FeedItem.tsx b/frontend/src/components/FeedItem.tsx
index 9b40114..ae4462a 100644
--- a/frontend/src/components/FeedItem.tsx
+++ b/frontend/src/components/FeedItem.tsx
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
import type { Item } from '../types';
import './FeedItem.css';
+import { apiFetch } from '../utils';
+
interface FeedItemProps {
item: Item;
}
@@ -24,7 +26,7 @@ export default function FeedItem({ item: initialItem }: FeedItemProps) {
const previousItem = item;
setItem(newItem);
- fetch(`/api/item/${newItem._id}`, {
+ apiFetch(`/api/item/${newItem._id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx
index 4d96da9..ca0dc98 100644
--- a/frontend/src/components/FeedItems.test.tsx
+++ b/frontend/src/components/FeedItems.test.tsx
@@ -21,7 +21,7 @@ describe('FeedItems Component', () => {
});
it('renders loading state', () => {
- (global.fetch as any).mockImplementation(() => new Promise(() => {}));
+ (global.fetch as any).mockImplementation(() => new Promise(() => { }));
render(
<MemoryRouter initialEntries={['/feed/1']}>
<Routes>
@@ -70,7 +70,7 @@ describe('FeedItems Component', () => {
const params = new URLSearchParams();
params.append('feed_id', '1');
params.append('read_filter', 'unread');
- expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything());
});
it('handles keyboard shortcuts', async () => {
@@ -138,7 +138,7 @@ describe('FeedItems Component', () => {
});
// Capture the callback
- let observerCallback: IntersectionObserverCallback = () => {};
+ let observerCallback: IntersectionObserverCallback = () => { };
// Override the mock to capture callback
class MockIntersectionObserver {
@@ -199,7 +199,7 @@ describe('FeedItems Component', () => {
.mockResolvedValueOnce({ ok: true, json: async () => initialItems })
.mockResolvedValueOnce({ ok: true, json: async () => moreItems });
- let observerCallback: IntersectionObserverCallback = () => {};
+ let observerCallback: IntersectionObserverCallback = () => { };
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
observerCallback = callback;
@@ -240,7 +240,7 @@ describe('FeedItems Component', () => {
const params = new URLSearchParams();
params.append('max_id', '101');
params.append('read_filter', 'unread');
- expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything());
});
});
});
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx
index 81c9139..b497e9d 100644
--- a/frontend/src/components/FeedItems.tsx
+++ b/frontend/src/components/FeedItems.tsx
@@ -3,6 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
import type { Item } from '../types';
import FeedItem from './FeedItem';
import './FeedItems.css';
+import { apiFetch } from '../utils';
export default function FeedItems() {
const { feedId, tagName } = useParams<{ feedId: string; tagName: string }>();
@@ -61,7 +62,7 @@ export default function FeedItems() {
url += `?${queryString}`;
}
- fetch(url)
+ apiFetch(url)
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch items');
@@ -103,7 +104,7 @@ export default function FeedItems() {
// Optimistic update
setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i)));
- fetch(`/api/item/${item._id}`, {
+ apiFetch(`/api/item/${item._id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true, starred: item.starred }),
@@ -115,7 +116,7 @@ export default function FeedItems() {
// Optimistic update
setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i)));
- fetch(`/api/item/${item._id}`, {
+ apiFetch(`/api/item/${item._id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: item.read, starred: !item.starred }),
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
index 497baf8..d2dc8e1 100644
--- a/frontend/src/components/FeedList.tsx
+++ b/frontend/src/components/FeedList.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams, useLocation, useParams } from 'react-router-dom';
import type { Feed, Category } from '../types';
import './FeedList.css';
+import { apiFetch } from '../utils';
export default function FeedList({
theme,
@@ -38,11 +39,11 @@ export default function FeedList({
useEffect(() => {
Promise.all([
- fetch('/api/feed/').then((res) => {
+ apiFetch('/api/feed/').then((res) => {
if (!res.ok) throw new Error('Failed to fetch feeds');
return res.json();
}),
- fetch('/api/tag').then((res) => {
+ apiFetch('/api/tag').then((res) => {
if (!res.ok) throw new Error('Failed to fetch tags');
return res.json();
}),
diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx
index 5f63248..ba2cd96 100644
--- a/frontend/src/components/Login.tsx
+++ b/frontend/src/components/Login.tsx
@@ -2,6 +2,8 @@ import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import './Login.css';
+import { apiFetch } from '../utils';
+
export default function Login() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -16,7 +18,7 @@ export default function Login() {
const params = new URLSearchParams();
params.append('password', password);
- const res = await fetch('/api/login', {
+ const res = await apiFetch('/api/login', {
method: 'POST',
body: params,
});
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx
index b4f6a3b..3f508e9 100644
--- a/frontend/src/components/Settings.tsx
+++ b/frontend/src/components/Settings.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import type { Feed } from '../types';
import './Settings.css';
+import { apiFetch } from '../utils';
export default function Settings() {
const [feeds, setFeeds] = useState<Feed[]>([]);
@@ -10,7 +11,7 @@ export default function Settings() {
const fetchFeeds = () => {
setLoading(true);
- fetch('/api/feed/')
+ apiFetch('/api/feed/')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch feeds');
return res.json();
@@ -36,7 +37,7 @@ export default function Settings() {
if (!newFeedUrl) return;
setLoading(true);
- fetch('/api/feed/', {
+ apiFetch('/api/feed/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: newFeedUrl }),
@@ -59,7 +60,7 @@ export default function Settings() {
if (!globalThis.confirm('Are you sure you want to delete this feed?')) return;
setLoading(true);
- fetch(`/api/feed/${id}`, {
+ apiFetch(`/api/feed/${id}`, {
method: 'DELETE',
})
.then((res) => {
diff --git a/frontend/src/components/TagView.test.tsx b/frontend/src/components/TagView.test.tsx
index 10872bc..8f7eb86 100644
--- a/frontend/src/components/TagView.test.tsx
+++ b/frontend/src/components/TagView.test.tsx
@@ -35,7 +35,7 @@ describe('Tag View Integration', () => {
render(
<MemoryRouter>
- <FeedList />
+ <FeedList theme="light" setTheme={() => { }} />
</MemoryRouter>
);
@@ -81,6 +81,6 @@ describe('Tag View Integration', () => {
const params = new URLSearchParams();
params.append('tag', 'Tech');
params.append('read_filter', 'unread');
- expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything());
});
});
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
new file mode 100644
index 0000000..129ebbb
--- /dev/null
+++ b/frontend/src/utils.ts
@@ -0,0 +1,31 @@
+export function getCookie(name: string): string | undefined {
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(';').shift();
+}
+
+/**
+ * A wrapper around fetch that automatically includes the CSRF token
+ * for state-changing requests (POST, PUT, DELETE).
+ */
+export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
+ const method = init?.method?.toUpperCase() || 'GET';
+ const isStateChanging = ['POST', 'PUT', 'DELETE'].includes(method);
+
+ const headers = new Headers(init?.headers || {});
+
+ if (isStateChanging) {
+ const token = getCookie('csrf_token');
+ if (token) {
+ headers.set('X-CSRF-Token', token);
+ }
+ }
+
+ // Ensure requests are treated as coming from our own origin if needed,
+ // but for a same-origin API, standard fetch defaults are usually fine.
+
+ return fetch(input, {
+ ...init,
+ headers,
+ });
+}