refactor(frontend): create centralised auth manager (#24)
- Resolves #17 Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
@@ -20,12 +20,11 @@ services:
|
|||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
extra_hosts:
|
network_mode: host
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- WEB_SERVER_URL=http://host.docker.internal:8080
|
- WEB_SERVER_URL=http://localhost:8080
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
- "1935:1935"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import StreamControl from "./pages/StreamControl.tsx";
|
|||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
import {config} from "./config.ts";
|
import {config} from "./config.ts";
|
||||||
import NotFound from "./pages/NotFound.tsx";
|
import NotFound from "./pages/NotFound.tsx";
|
||||||
|
import AuthProvider from "./auth/AuthProvider.tsx";
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -26,24 +27,26 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
<GoogleOAuthProvider clientId={config.googleClientId}>
|
||||||
<Router>
|
<AuthProvider>
|
||||||
<Routes>
|
<Router>
|
||||||
<Route element={<MainLayout />}>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route element={<MainLayout />}>
|
||||||
<Route path="/create" element={<ClipUpload />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/create/:id" element={<ClipEdit />} />
|
<Route path="/create" element={<ClipUpload />} />
|
||||||
<Route path="/my-clips" element={<MyClips />} />
|
<Route path="/create/:id" element={<ClipEdit />} />
|
||||||
<Route path="/my-vods" element={<MyVods />} />
|
<Route path="/my-clips" element={<MyClips />} />
|
||||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
<Route path="/my-vods" element={<MyVods />} />
|
||||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/stream" element={<StreamControl />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="/stream" element={<StreamControl />} />
|
||||||
</Route>
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Route>
|
||||||
</Router>
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
</ GoogleOAuthProvider>
|
</ GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
frontend/src/auth/AuthContext.ts
Normal file
20
frontend/src/auth/AuthContext.ts
Normal 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 };
|
||||||
46
frontend/src/auth/AuthProvider.tsx
Normal file
46
frontend/src/auth/AuthProvider.tsx
Normal 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;
|
||||||
14
frontend/src/auth/useAuth.ts
Normal file
14
frontend/src/auth/useAuth.ts
Normal 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 };
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
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 = {
|
type props = {
|
||||||
user: User | null;
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
|
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 (
|
return (
|
||||||
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
|
<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>
|
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import { Menu, X } from 'lucide-react';
|
import { Menu, X } from 'lucide-react';
|
||||||
import { login, logout } from "../utils/api/users.ts";
|
|
||||||
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
||||||
import { GoogleLogin } from '@react-oauth/google';
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
|
|
||||||
import type { User } from "../utils/types.ts";
|
|
||||||
import type { CredentialResponse } from '@react-oauth/google';
|
import type { CredentialResponse } from '@react-oauth/google';
|
||||||
|
|
||||||
import MenuButton from "./buttons/MenuButton.tsx";
|
import MenuButton from "./buttons/MenuButton.tsx";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
|
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
sidebarToggled: boolean;
|
sidebarToggled: boolean;
|
||||||
setSidebarToggled: (toggled: boolean) => void;
|
setSidebarToggled: (toggled: boolean) => void;
|
||||||
user: User | null;
|
|
||||||
fetchUser: () => void;
|
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -23,12 +20,11 @@ type props = {
|
|||||||
const Topbar = ({
|
const Topbar = ({
|
||||||
sidebarToggled,
|
sidebarToggled,
|
||||||
setSidebarToggled,
|
setSidebarToggled,
|
||||||
user,
|
|
||||||
fetchUser,
|
|
||||||
isStreaming,
|
isStreaming,
|
||||||
className}: props) => {
|
className}: props) => {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user, login, logout } = useAuth();
|
||||||
|
|
||||||
const handleLogin = (response: CredentialResponse) => {
|
const handleLogin = (response: CredentialResponse) => {
|
||||||
if (!response.credential) {
|
if (!response.credential) {
|
||||||
@@ -37,10 +33,6 @@ const Topbar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
login(response.credential)
|
login(response.credential)
|
||||||
.then(() => {
|
|
||||||
fetchUser();
|
|
||||||
navigate(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Login failed:", error);
|
console.error("Login failed:", error);
|
||||||
});
|
});
|
||||||
@@ -48,10 +40,7 @@ const Topbar = ({
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
.then(() => {
|
.then(() => navigate("/"))
|
||||||
fetchUser();
|
|
||||||
navigate("/");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,27 +3,13 @@ import Sidebar from '../components/Sidebar'
|
|||||||
import Topbar from '../components/Topbar'
|
import Topbar from '../components/Topbar'
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import type {User} from "../utils/types";
|
import { useAuth } from "../auth/useAuth";
|
||||||
import { getUser } from "../utils/api/users";
|
|
||||||
import { getStreamStatus } from "../utils/api/stream";
|
import { getStreamStatus } from "../utils/api/stream";
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const [sidebarToggled, setSidebarToggled] = useState(false);
|
const [sidebarToggled, setSidebarToggled] = useState(false);
|
||||||
const [user, setUser] = useState<null | User>(null);
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const { user } = useAuth();
|
||||||
const fetchUser = async () => {
|
|
||||||
try {
|
|
||||||
const userData = await getUser();
|
|
||||||
setUser(userData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch user:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchUser();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -36,14 +22,11 @@ const MainLayout = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
|
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
user={user}
|
|
||||||
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
||||||
<Topbar
|
<Topbar
|
||||||
className={"transition-all duration-300"}
|
className={"transition-all duration-300"}
|
||||||
sidebarToggled={sidebarToggled}
|
sidebarToggled={sidebarToggled}
|
||||||
setSidebarToggled={setSidebarToggled}
|
setSidebarToggled={setSidebarToggled}
|
||||||
user={user}
|
|
||||||
fetchUser={fetchUser}
|
|
||||||
isStreaming={isStreaming}/>
|
isStreaming={isStreaming}/>
|
||||||
<div className="overflow-auto bg-background">
|
<div className="overflow-auto bg-background">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import { getUser } from "../utils/api/users.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
import type { User } from "../utils/types.ts";
|
|
||||||
import { formatLocalDate } from "../utils/utils.ts";
|
import { formatLocalDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const { user } = useAuth();
|
||||||
const [keyVisible, setKeyVisible] = useState(false);
|
const [keyVisible, setKeyVisible] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getUser().then(setUser).catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!user?.streamKey) return;
|
if (!user?.streamKey) return;
|
||||||
navigator.clipboard.writeText(user.streamKey);
|
navigator.clipboard.writeText(user.streamKey);
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lu
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.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 { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
||||||
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
||||||
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
||||||
import { getJob } from "../utils/api/jobs.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";
|
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";
|
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 StreamControl = () => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const { user } = useAuth();
|
||||||
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
||||||
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
@@ -85,10 +85,6 @@ const StreamControl = () => {
|
|||||||
|
|
||||||
const isStreaming = streamStatus?.isStreaming ?? false;
|
const isStreaming = streamStatus?.isStreaming ?? false;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getUser().then(setUser).catch(() => setUser(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user