refactor(frontend): create centralised auth manager (#24)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

- Resolves #17

Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
2026-08-22 21:06:38 +01:00
parent bf5fe6a35d
commit 38942776df
10 changed files with 119 additions and 73 deletions

View File

@@ -20,12 +20,11 @@ services:
context: ./nginx
dockerfile: Dockerfile
container_name: vod_rtmp
extra_hosts:
- "host.docker.internal:host-gateway"
network_mode: host
env_file:
- .env
environment:
- WEB_SERVER_URL=http://host.docker.internal:8080
- WEB_SERVER_URL=http://localhost:8080
ports:
- "1935:1935"
volumes:

View File

@@ -17,6 +17,7 @@ import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google';
import {config} from "./config.ts";
import NotFound from "./pages/NotFound.tsx";
import AuthProvider from "./auth/AuthProvider.tsx";
function App() {
@@ -26,24 +27,26 @@ function App() {
return (
<GoogleOAuthProvider clientId={config.googleClientId}>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
<AuthProvider>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</AuthProvider>
</ GoogleOAuthProvider>
);
}

View File

@@ -0,0 +1,20 @@
import { createContext } from "react";
import type { User } from "../utils/types.ts";
type AuthContextValue = {
/** The currently authenticated user, or null when logged out. */
user: User | null;
/** True until the initial "who am I" request has resolved. */
loading: boolean;
/** Exchange a Google credential for a session, then load the user. */
login: (googleCredential: string) => Promise<void>;
/** End the session and clear the user. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). */
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export { AuthContext };
export type { AuthContextValue };

View File

@@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from "react";
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";
/**
* Owns the single source of truth for the authenticated user. Everything that
* needs to know who is logged in — the layout, the top bar, protected pages —
* reads it through the `useAuth` hook rather than fetching the user itself.
*/
const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
setUser(await getUser());
} catch (error) {
console.error("Failed to fetch user:", error);
setUser(null);
}
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
}, [refresh]);
const login = useCallback(async (googleCredential: string) => {
await apiLogin(googleCredential);
await refresh();
}, [refresh]);
const logout = useCallback(async () => {
await apiLogout();
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;

View File

@@ -0,0 +1,14 @@
import { useContext } from "react";
import { AuthContext } from "./AuthContext.ts";
import type { AuthContextValue } from "./AuthContext.ts";
/** Access the centralised auth state. Must be used within an <AuthProvider>. */
const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export { useAuth };

View File

@@ -1,16 +1,17 @@
import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
import type {User} from "../utils/types.ts";
import { useAuth } from "../auth/useAuth.ts";
type props = {
user: User | null;
className?: string
}
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({user, className}: props) => {
const Sidebar = ({className}: props) => {
const { user } = useAuth();
return (
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>

View File

@@ -1,21 +1,18 @@
import { Menu, X } from 'lucide-react';
import { login, logout } from "../utils/api/users.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
import type { User } from "../utils/types.ts";
import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
import { useAuth } from "../auth/useAuth.ts";
type props = {
sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void;
user: User | null;
fetchUser: () => void;
isStreaming: boolean;
className?: string;
}
@@ -23,12 +20,11 @@ type props = {
const Topbar = ({
sidebarToggled,
setSidebarToggled,
user,
fetchUser,
isStreaming,
className}: props) => {
const navigate = useNavigate();
const { user, login, logout } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
@@ -37,10 +33,6 @@ const Topbar = ({
}
login(response.credential)
.then(() => {
fetchUser();
navigate(0);
})
.catch((error) => {
console.error("Login failed:", error);
});
@@ -48,10 +40,7 @@ const Topbar = ({
const handleLogout = () => {
logout()
.then(() => {
fetchUser();
navigate("/");
})
.then(() => navigate("/"))
.catch((error) => {
console.error("Logout failed:", error);
});

View File

@@ -3,27 +3,13 @@ import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react";
import type {User} from "../utils/types";
import { getUser } from "../utils/api/users";
import { useAuth } from "../auth/useAuth";
import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null);
const [isStreaming, setIsStreaming] = useState(false);
const fetchUser = async () => {
try {
const userData = await getUser();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
}
};
useEffect(() => {
fetchUser();
}, []);
const { user } = useAuth();
useEffect(() => {
if (!user) return;
@@ -36,14 +22,11 @@ const MainLayout = () => {
return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar
user={user}
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<Topbar
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
user={user}
fetchUser={fetchUser}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet />

View File

@@ -1,19 +1,14 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Box from "../components/Box.tsx";
import { getUser } from "../utils/api/users.ts";
import type { User } from "../utils/types.ts";
import { useAuth } from "../auth/useAuth.ts";
import { formatLocalDate } from "../utils/utils.ts";
const Profile = () => {
const [user, setUser] = useState<User | null>(null);
const { user } = useAuth();
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
getUser().then(setUser).catch(console.error);
}, []);
const handleCopy = () => {
if (!user?.streamKey) return;
navigator.clipboard.writeText(user.streamKey);

View File

@@ -3,12 +3,12 @@ import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lu
import clsx from "clsx";
import Box from "../components/Box.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
import { getUser } from "../utils/api/users.ts";
import { useAuth } from "../auth/useAuth.ts";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts";
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
@@ -60,7 +60,7 @@ const JobStatus = ({ job }: { job: TrackedJob }) => {
};
const StreamControl = () => {
const [user, setUser] = useState<User | null>(null);
const { user } = useAuth();
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [now, setNow] = useState(Date.now());
@@ -85,10 +85,6 @@ const StreamControl = () => {
const isStreaming = streamStatus?.isStreaming ?? false;
useEffect(() => {
getUser().then(setUser).catch(() => setUser(null));
}, []);
useEffect(() => {
if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});