diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a6f589c..32b9884 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -19,6 +19,7 @@ import {config} from "./config.ts";
import NotFound from "./pages/NotFound.tsx";
import AuthProvider from "./auth/AuthProvider.tsx";
import ThemeProvider from "./theme/ThemeProvider.tsx";
+ import RequireAuth from "./auth/RequireAuth.tsx";
function App() {
@@ -36,14 +37,18 @@ function App() {
} />
} />
} />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
} />
diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx
index 1091794..b1ee12e 100644
--- a/frontend/src/auth/AuthProvider.tsx
+++ b/frontend/src/auth/AuthProvider.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import type { User } from "../utils/types.ts";
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
import { AuthContext } from "./AuthContext.ts";
+import {registerUnauthorizedHandler} from "../utils/api/client.ts";
/**
* Owns the single source of truth for the authenticated user. Everything that
@@ -33,7 +34,11 @@ const AuthProvider = ({ children }: { children: ReactNode }) => {
const logout = useCallback(async () => {
await apiLogout();
- setUser(null);
+ await refresh();
+ }, [refresh]);
+
+ useEffect(() => {
+ registerUnauthorizedHandler(() => setUser(null));
}, []);
return (
diff --git a/frontend/src/auth/RequireAuth.tsx b/frontend/src/auth/RequireAuth.tsx
new file mode 100644
index 0000000..b3e6fb8
--- /dev/null
+++ b/frontend/src/auth/RequireAuth.tsx
@@ -0,0 +1,14 @@
+import { Navigate, Outlet, useLocation } from "react-router-dom";
+import { useAuth } from "./useAuth";
+
+const RequireAuth = () => {
+ const { user, loading } = useAuth();
+ const location = useLocation();
+
+ if (loading) return null;
+ if (!user) return ;
+
+ return ;
+};
+
+export default RequireAuth;
\ No newline at end of file
diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx
index 3188ef3..383883e 100644
--- a/frontend/src/pages/ClipEdit.tsx
+++ b/frontend/src/pages/ClipEdit.tsx
@@ -6,13 +6,10 @@ import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx";
import { compress } from "../utils/api/media"
-import { getJob } from "../utils/api/jobs"
+import { getJob, downloadJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx";
-import {config} from "../config.ts";
-
-const API_URL = config.apiUrl;
const ClipEdit = () => {
const location = useLocation();
@@ -111,14 +108,14 @@ const ClipEdit = () => {
const handleDownload = async () => {
if (!uploadedId) return;
- const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
-
- if (!response.ok) {
- console.error('Download failed');
+ let blob: Blob;
+ try {
+ blob = await downloadJob(uploadedId);
+ } catch (err) {
+ console.error('Download failed', err);
return;
}
- const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
diff --git a/frontend/src/utils/api/client.ts b/frontend/src/utils/api/client.ts
index 8d6df5f..51d8337 100644
--- a/frontend/src/utils/api/client.ts
+++ b/frontend/src/utils/api/client.ts
@@ -6,9 +6,43 @@ class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; }
}
-const isThumbnailAvailable = async (thumbnailUrl: string): Promise => {
- const response = await fetch(thumbnailUrl, { credentials: 'include' });
- return response.ok;
-};
+let onUnauthorized: (() => void) | null = null;
-export { API_URL, AuthError, isThumbnailAvailable };
+export function registerUnauthorizedHandler(handler: () => void) {
+ onUnauthorized = handler;
+}
+
+/**
+ * Performs a request against the API, sending session cookies and funnelling
+ * expired/missing sessions through the registered unauthorized handler so the
+ * UI can drop back to a logged out state. Returns the raw response so callers
+ * that need something other than JSON (blobs, streams) can read it themselves.
+ */
+async function apiFetchRaw(url: string, options: RequestInit = {}): Promise {
+ const response = await fetch(url, {
+ ...options,
+ credentials: 'include'
+ });
+
+ if (response.status == 401 || response.status == 403) {
+ onUnauthorized?.();
+ throw new AuthError();
+ }
+
+ if (!response.ok) {
+ const text = await response.text();
+ let message = text;
+ try { message = JSON.parse(text).message ?? text; } catch { /* not JSON, use raw text */ }
+ throw new Error(message || `Request failed: ${response.status}`);
+ }
+
+ return response;
+}
+
+async function apiFetch(url: string, options: RequestInit = {}): Promise {
+ const response = await apiFetchRaw(url, options);
+ const text = await response.text();
+ return text ? JSON.parse(text) : (undefined as T);
+}
+
+export { API_URL, AuthError, apiFetch, apiFetchRaw };
diff --git a/frontend/src/utils/api/clips.ts b/frontend/src/utils/api/clips.ts
index d042029..e7051cf 100644
--- a/frontend/src/utils/api/clips.ts
+++ b/frontend/src/utils/api/clips.ts
@@ -1,27 +1,17 @@
import type { APIResponse, Clip } from "../types.ts";
-import { API_URL, AuthError } from "./client.ts";
+import { API_URL, apiFetch } from "./client.ts";
const getClips = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
+ const result = await apiFetch(API_URL + '/api/v1/clips');
+ if (result.status === 'error') throw new Error(`Failed to fetch clips: ${result.message}`);
- if (!response.ok) {
- const errorResult: APIResponse = await response.json();
- throw new Error(`Failed to fetch clips: ${errorResult.message}`);
- }
-
- const result: APIResponse = await response.json();
return result.data;
};
const getClipById = async (id: string): Promise => {
- const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
+ const result = await apiFetch(API_URL + `/api/v1/clips/${id}`);
+ if (result.status === 'error') throw new Error(`Failed to fetch clip: ${result.message}`);
- if (!response.ok) {
- if (response.status === 401 || response.status === 403) throw new AuthError();
- throw new Error(`Failed to fetch clip: ${response.status}`);
- }
-
- const result: APIResponse = await response.json();
return result.data;
};
@@ -31,31 +21,20 @@ const getClipById = async (id: string): Promise => {
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise => {
- const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
+ const result = await apiFetch(API_URL + `/api/v1/clips/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify(data),
});
- if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
-
- const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
return result.data;
};
const deleteClip = async (id: number): Promise => {
- const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
- method: 'DELETE',
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
+ const result = await apiFetch(API_URL + `/api/v1/clips/${id}`, { method: 'DELETE' });
+ if (result?.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
};
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
diff --git a/frontend/src/utils/api/jobs.ts b/frontend/src/utils/api/jobs.ts
index 5f3bfe4..ebad568 100644
--- a/frontend/src/utils/api/jobs.ts
+++ b/frontend/src/utils/api/jobs.ts
@@ -1,15 +1,16 @@
import type { APIResponse, JobResponse } from "../types.ts";
-import { API_URL } from "./client.ts";
+import { API_URL, apiFetch, apiFetchRaw } from "./client.ts";
const getJob = async (uuid: string): Promise => {
- const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
-
- if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
-
- const result: APIResponse = await response.json();
+ const result = await apiFetch(API_URL + `/api/v1/jobs/${uuid}`);
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
return result.data;
};
-export { getJob };
+const downloadJob = async (uuid: string): Promise => {
+ const response = await apiFetchRaw(API_URL + `/api/v1/jobs/${uuid}/download`);
+ return response.blob();
+};
+
+export { getJob, downloadJob };
diff --git a/frontend/src/utils/api/markers.ts b/frontend/src/utils/api/markers.ts
index 8eadd80..3dee8ef 100644
--- a/frontend/src/utils/api/markers.ts
+++ b/frontend/src/utils/api/markers.ts
@@ -1,40 +1,28 @@
import type { APIResponse, Marker } from "../types.ts";
-import { API_URL } from "./client.ts";
+import { API_URL, apiFetch } from "./client.ts";
const getMarkers = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' });
- if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`);
-
- const result: APIResponse = await response.json();
+ const result = await apiFetch(API_URL + '/api/v1/markers');
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
return result.data;
};
const createMarker = async (message: string): Promise => {
- const response = await fetch(API_URL + '/api/v1/markers', {
+ const result = await apiFetch(API_URL + '/api/v1/markers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify({ message }),
});
- const result: APIResponse = await response.json();
- if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
+ if (result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
return result.data;
};
const deleteMarker = async (id: number): Promise => {
- const response = await fetch(API_URL + `/api/v1/markers/${id}`, {
- method: 'DELETE',
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Failed to delete marker: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
+ const result = await apiFetch(API_URL + `/api/v1/markers/${id}`, { method: 'DELETE' });
+ if (result?.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
};
export { getMarkers, createMarker, deleteMarker };
diff --git a/frontend/src/utils/api/media.ts b/frontend/src/utils/api/media.ts
index 5d8626c..5f8d049 100644
--- a/frontend/src/utils/api/media.ts
+++ b/frontend/src/utils/api/media.ts
@@ -1,5 +1,5 @@
import type { APIResponse, VideoMetadata } from "../types.ts";
-import { API_URL } from "./client.ts";
+import { API_URL, apiFetch } from "./client.ts";
const compress = async (file: File, options: VideoMetadata): Promise => {
const formData = new FormData();
@@ -13,58 +13,48 @@ const compress = async (file: File, options: VideoMetadata): Promise =>
if (options.height) formData.append('height', options.height.toString());
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
- const response = await fetch(API_URL + '/api/v1/media/compress', {
+ const result = await apiFetch(API_URL + '/api/v1/media/compress', {
method: 'POST',
body: formData,
- credentials: 'include',
});
- if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
-
- const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
return result.data.uuid;
};
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise => {
- const response = await fetch(API_URL + '/api/v1/media/save', {
+ const result = await apiFetch(API_URL + '/api/v1/media/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify({ startTime, endTime, title, description }),
});
- const result: APIResponse = await response.json();
- if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
+ if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
return result.data.uuid;
};
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise => {
- const response = await fetch(API_URL + '/api/v1/media/save/markers', {
+ const result = await apiFetch(API_URL + '/api/v1/media/save/markers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
});
- const result: APIResponse = await response.json();
- if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
+ if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
return result.data.uuid;
};
const clipSection = async (duration: number, title?: string, description?: string): Promise => {
- const response = await fetch(API_URL + '/api/v1/media/clip', {
+ const result = await apiFetch(API_URL + '/api/v1/media/clip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify({ duration, title, description }),
});
- const result: APIResponse = await response.json();
- if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
+ if (result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
return result.data.uuid;
};
diff --git a/frontend/src/utils/api/stream.ts b/frontend/src/utils/api/stream.ts
index 7cf5fc0..a27ab6d 100644
--- a/frontend/src/utils/api/stream.ts
+++ b/frontend/src/utils/api/stream.ts
@@ -1,28 +1,24 @@
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
-import { API_URL } from "./client.ts";
+import { API_URL, apiFetch } from "./client.ts";
const getStreamStatus = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
- if (!response.ok) return false;
- const result: APIResponse = await response.json();
- return result.status === 'success' && result.data?.isStreaming === true;
+ try {
+ const result = await apiFetch(API_URL + '/api/v1/stream/current');
+ return result.status === 'success' && result.data?.isStreaming === true;
+ } catch {
+ return false;
+ }
};
const getCurrentStream = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
- if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
-
- const result: APIResponse = await response.json();
+ const result = await apiFetch(API_URL + '/api/v1/stream/current');
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
return result.data;
};
const getStreamHistory = async (userId: number): Promise => {
- const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
- if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
-
- const result: APIResponse = await response.json();
+ const result = await apiFetch(API_URL + `/api/v1/stream/history/${userId}`);
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
return result.data;
diff --git a/frontend/src/utils/api/users.ts b/frontend/src/utils/api/users.ts
index 73f4af1..506a454 100644
--- a/frontend/src/utils/api/users.ts
+++ b/frontend/src/utils/api/users.ts
@@ -1,6 +1,9 @@
import type { APIResponse, User } from "../types.ts";
-import { API_URL } from "./client.ts";
+import { API_URL, apiFetch } from "./client.ts";
+// login and getUser deliberately use fetch directly: they run when there may be
+// no session at all, and getUser is what the unauthorized handler calls to
+// refresh — routing them through apiFetch would loop on a 401.
const login = async (googleToken: string): Promise => {
const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST',
@@ -18,15 +21,8 @@ const login = async (googleToken: string): Promise => {
};
const logout = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/users/logout', {
- method: 'POST',
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
+ const result = await apiFetch(API_URL + '/api/v1/users/logout', { method: 'POST' });
+ if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
const getUser = async (): Promise => {
diff --git a/frontend/src/utils/api/vods.ts b/frontend/src/utils/api/vods.ts
index f4f3484..88fb94b 100644
--- a/frontend/src/utils/api/vods.ts
+++ b/frontend/src/utils/api/vods.ts
@@ -1,27 +1,17 @@
import type { APIResponse, Vod } from "../types.ts";
-import { API_URL, AuthError } from "./client.ts";
+import { API_URL, apiFetch } from "./client.ts";
const getVods = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
+ const result = await apiFetch(API_URL + '/api/v1/vods');
+ if (result.status === 'error') throw new Error(`Failed to fetch vods: ${result.message}`);
- if (!response.ok) {
- const errorResult: APIResponse = await response.json();
- throw new Error(`Failed to fetch vods: ${errorResult.message}`);
- }
-
- const result: APIResponse = await response.json();
return result.data;
};
const getVodById = async (id: string): Promise => {
- const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
+ const result = await apiFetch(API_URL + `/api/v1/vods/${id}`);
+ if (result.status === 'error') throw new Error(`Failed to fetch vod: ${result.message}`);
- if (!response.ok) {
- if (response.status === 401 || response.status === 403) throw new AuthError();
- throw new Error(`Failed to fetch vod: ${response.status}`);
- }
-
- const result: APIResponse = await response.json();
return result.data;
};
@@ -31,31 +21,20 @@ const getVodById = async (id: string): Promise => {
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise => {
- const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
+ const result = await apiFetch(API_URL + `/api/v1/vods/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify(data),
});
- if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
-
- const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
return result.data;
};
const deleteVod = async (id: number): Promise => {
- const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
- method: 'DELETE',
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
+ const result = await apiFetch(API_URL + `/api/v1/vods/${id}`, { method: 'DELETE' });
+ if (result?.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
};
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };