From aa15dc0f54fb269cafbdb3275ea6a1a18c763d5c Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sat, 14 Mar 2026 14:31:08 +0100 Subject: [PATCH] feat: AuthContext, protected routes, admin page, fix VideoPlayer URL --- frontend/src/App.tsx | 32 +- frontend/src/components/RequireAuth.tsx | 15 + frontend/src/components/VideoPlayer.tsx | 3 +- frontend/src/components/layout/Header.tsx | 7 +- frontend/src/components/layout/Layout.tsx | 4 +- frontend/src/context/AuthContext.tsx | 50 +++ frontend/src/pages/AdminPage.tsx | 439 ++++++++++++++++++++++ frontend/src/pages/LoginPage.tsx | 6 +- 8 files changed, 538 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/RequireAuth.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/pages/AdminPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e2a157..79a6da7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,15 @@ import { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext'; import Layout from './components/layout/Layout'; +import RequireAuth from './components/RequireAuth'; import HomePage from './pages/HomePage'; import LoginPage from './pages/LoginPage'; import CallbackPage from './pages/CallbackPage'; import VideoPage from './pages/VideoPage'; import PlaylistsPage from './pages/PlaylistsPage'; import PlaylistPage from './pages/PlaylistPage'; +import AdminPage from './pages/AdminPage'; type Theme = 'dark' | 'light'; @@ -23,18 +26,23 @@ function App() { const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')); return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + }> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + + + + + ); } diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx new file mode 100644 index 0000000..48fb982 --- /dev/null +++ b/frontend/src/components/RequireAuth.tsx @@ -0,0 +1,15 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useAuthContext } from '../context/AuthContext'; + +export default function RequireAuth() { + const { user, loading } = useAuthContext(); + const location = useLocation(); + + if (loading) return null; + + if (!user) { + return ; + } + + return ; +} diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index ac3dde3..33a74a9 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -20,10 +20,11 @@ export default function VideoPlayer({ storageType, storageKey }: VideoPlayerProp return ; } + const apiBase = import.meta.env.VITE_API_URL || '/api'; const url = storageType === 'external' ? storageKey - : `${import.meta.env.VITE_API_URL}/stream/${storageKey}`; + : `${apiBase}/stream/${storageKey}`; return ; } diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 952461f..0720efc 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; import { apiFetch } from '../../lib/api'; -import type { User } from '../../hooks/useAuth'; +import type { User } from '../../context/AuthContext'; interface HeaderProps { theme: 'dark' | 'light'; @@ -39,6 +39,11 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP Playlists )} + {user && ( + + admin + + )} {/* Right — thème + auth */} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index bc75d83..ca5c22e 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,6 +1,6 @@ import { Outlet } from 'react-router-dom'; import Header from './Header'; -import { useAuth } from '../../hooks/useAuth'; +import { useAuthContext } from '../../context/AuthContext'; interface LayoutProps { theme: 'dark' | 'light'; @@ -8,7 +8,7 @@ interface LayoutProps { } export default function Layout({ theme, onToggleTheme }: LayoutProps) { - const { user, loading, setUser } = useAuth(); + const { user, loading, setUser } = useAuthContext(); return (
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..e8146e3 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; +import { apiFetch } from '../lib/api'; + +export interface User { + id: string; + email: string | null; + nickname: string; + subscriptionLevel?: number; +} + +interface AuthContextValue { + user: User | null; + loading: boolean; + setUser: (u: User | null) => void; +} + +const AuthContext = createContext(null); + +interface MeResponse { + success: boolean; + data: { user: User }; +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + apiFetch('/auth/me') + .then((res) => { if (!cancelled) setUser(res.data.user); }) + .catch(() => { if (!cancelled) setUser(null); }) + .finally(() => { if (!cancelled) setLoading(false); }); + + return () => { cancelled = true; }; + }, []); + + return ( + + {children} + + ); +} + +export function useAuthContext(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuthContext must be used inside AuthProvider'); + return ctx; +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx new file mode 100644 index 0000000..0421e2d --- /dev/null +++ b/frontend/src/pages/AdminPage.tsx @@ -0,0 +1,439 @@ +import { useState, useEffect } from 'react'; +import { apiFetch } from '../lib/api'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface Video { + id: string; + title: string; + storageType: string; + storageKey: string; + requiredLevel: number; + isPublished: boolean; + createdAt: string; +} + +interface Plan { + id: string; + slug: string; + name: string; + level: number; + priceInCents: number; + isActive: boolean; +} + +interface AdminUser { + id: string; + email: string | null; + nickname: string; + isActive: boolean; + createdAt: string; + roles: { id: string; slug: string; name: string }[]; + activeSubscription: { + id: string; + status: string; + endsAt: string | null; + plan: Plan; + } | null; +} + +// ── Tabs ───────────────────────────────────────────────────────────────────── + +type Tab = 'videos' | 'users' | 'plans'; + +export default function AdminPage() { + const [tab, setTab] = useState('videos'); + + return ( +
+
+ {(['videos', 'users', 'plans'] as Tab[]).map((t) => ( + + ))} +
+ + {tab === 'videos' && } + {tab === 'users' && } + {tab === 'plans' && } +
+ ); +} + +// ── Videos tab ─────────────────────────────────────────────────────────────── + +function VideosTab() { + const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); + const [form, setForm] = useState({ + title: '', storageType: 'youtube', storageKey: '', + requiredLevel: 0, isPublished: false, + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos') + .then((r) => setVideos(r.data.videos)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!form.title || !form.storageKey || saving) return; + setSaving(true); + setError(null); + try { + const r = await apiFetch<{ success: boolean; data: { video: Video } }>( + '/admin/videos', + { method: 'POST', body: JSON.stringify(form) } + ); + setVideos((v) => [r.data.video, ...v]); + setForm({ title: '', storageType: 'youtube', storageKey: '', requiredLevel: 0, isPublished: false }); + } catch { + setError('Erreur lors de la création.'); + } + setSaving(false); + } + + async function togglePublish(video: Video) { + try { + const r = await apiFetch<{ success: boolean; data: { video: Video } }>( + `/admin/videos/${video.id}`, + { method: 'PATCH', body: JSON.stringify({ isPublished: !video.isPublished }) } + ); + setVideos((v) => v.map((x) => x.id === video.id ? r.data.video : x)); + } catch {} + } + + async function handleDelete(id: string) { + if (!confirm('Supprimer cette vidéo ?')) return; + try { + await apiFetch(`/admin/videos/${id}`, { method: 'DELETE' }); + setVideos((v) => v.filter((x) => x.id !== id)); + } catch {} + } + + return ( +
+ + {/* Formulaire création */} +
+

Nouvelle vidéo

+ + setForm((f) => ({ ...f, title: e.target.value }))} + placeholder="Titre" + required + className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent" + /> + +
+ + setForm((f) => ({ ...f, storageKey: e.target.value }))} + placeholder={form.storageType === 'youtube' ? 'ID YouTube (ex: dQw4w9WgXcQ)' : 'Chemin / URL'} + required + className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent" + /> +
+ +
+ + +
+ + {error &&

{error}

} + +
+ + {/* Liste */} + {loading ? ( +
+ {[...Array(3)].map((_, i) =>
)} +
+ ) : ( +
+ {videos.map((v) => ( +
+
+

{v.title}

+

{v.storageType} · niveau {v.requiredLevel}

+
+ + +
+ ))} + {videos.length === 0 &&

Aucune vidéo.

} +
+ )} +
+ ); +} + +// ── Users tab ──────────────────────────────────────────────────────────────── + +function UsersTab() { + const [users, setUsers] = useState([]); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [assigning, setAssigning] = useState(null); + const [selectedPlan, setSelectedPlan] = useState>({}); + + useEffect(() => { + Promise.all([ + apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'), + apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans'), + ]) + .then(([ur, pr]) => { + setUsers(ur.data.users); + setPlans(pr.data.plans); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + async function assignPlan(userId: string) { + const planId = selectedPlan[userId]; + if (!planId || assigning) return; + setAssigning(userId); + try { + await apiFetch(`/admin/users/${userId}/subscriptions`, { + method: 'POST', + body: JSON.stringify({ planId }), + }); + // Rafraîchir la liste users + const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'); + setUsers(r.data.users); + } catch {} + setAssigning(null); + } + + if (loading) return
; + + return ( +
+ {users.map((u) => ( +
+
+
+

{u.nickname}

+

{u.email ?? '—'}

+
+
+ {u.activeSubscription ? ( + + {u.activeSubscription.plan.name} + {u.activeSubscription.endsAt && ` · ${new Date(u.activeSubscription.endsAt).toLocaleDateString()}`} + + ) : ( + free + )} +
+
+ +
+ + +
+
+ ))} + {users.length === 0 &&

Aucun utilisateur.

} +
+ ); +} + +// ── Plans tab ───────────────────────────────────────────────────────────────── + +function PlansTab() { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [form, setForm] = useState({ slug: '', name: '', level: 1, priceInCents: 0 }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans') + .then((r) => setPlans(r.data.plans)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!form.slug || !form.name || saving) return; + setSaving(true); + setError(null); + try { + const r = await apiFetch<{ success: boolean; data: { plan: Plan } }>( + '/admin/plans', + { method: 'POST', body: JSON.stringify(form) } + ); + setPlans((p) => [...p, r.data.plan]); + setForm({ slug: '', name: '', level: 1, priceInCents: 0 }); + } catch { + setError('Erreur lors de la création.'); + } + setSaving(false); + } + + async function toggleActive(plan: Plan) { + try { + const r = await apiFetch<{ success: boolean; data: { plan: Plan } }>( + `/admin/plans/${plan.id}`, + { method: 'PATCH', body: JSON.stringify({ isActive: !plan.isActive }) } + ); + setPlans((p) => p.map((x) => x.id === plan.id ? r.data.plan : x)); + } catch {} + } + + if (loading) return
; + + return ( +
+ +
+

Nouveau plan

+
+ setForm((f) => ({ ...f, slug: e.target.value }))} + placeholder="slug (ex: premium)" + required + className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent" + /> + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="Nom" + required + className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent" + /> +
+
+ + +
+ {error &&

{error}

} + +
+ +
+ {plans.map((p) => ( +
+
+

{p.name}

+

+ {p.slug} · niv. {p.level} · {(p.priceInCents / 100).toFixed(2)} € +

+
+ +
+ ))} + {plans.length === 0 &&

Aucun plan.

} +
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 6dd2396..c83a234 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; import { apiFetch } from '../lib/api'; const PROVIDERS = [ @@ -11,6 +11,8 @@ const PROVIDERS = [ export default function LoginPage() { const navigate = useNavigate(); + const location = useLocation(); + const from = (location.state as { from?: Location })?.from?.pathname ?? '/'; const base = import.meta.env.VITE_SUPEROAUTH_URL; const redirectUrl = encodeURIComponent(window.location.origin + '/callback'); @@ -29,7 +31,7 @@ export default function LoginPage() { method: 'POST', body: JSON.stringify({ email, password }), }); - navigate('/', { replace: true }); + navigate(from, { replace: true }); } catch { setError('Email ou mot de passe incorrect.'); } finally {