feat: AuthContext, protected routes, admin page, fix VideoPlayer URL
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import Layout from './components/layout/Layout';
|
import Layout from './components/layout/Layout';
|
||||||
|
import RequireAuth from './components/RequireAuth';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import CallbackPage from './pages/CallbackPage';
|
import CallbackPage from './pages/CallbackPage';
|
||||||
import VideoPage from './pages/VideoPage';
|
import VideoPage from './pages/VideoPage';
|
||||||
import PlaylistsPage from './pages/PlaylistsPage';
|
import PlaylistsPage from './pages/PlaylistsPage';
|
||||||
import PlaylistPage from './pages/PlaylistPage';
|
import PlaylistPage from './pages/PlaylistPage';
|
||||||
|
import AdminPage from './pages/AdminPage';
|
||||||
|
|
||||||
type Theme = 'dark' | 'light';
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
@@ -23,18 +26,23 @@ function App() {
|
|||||||
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route element={<Layout theme={theme} onToggleTheme={toggleTheme} />}>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route element={<Layout theme={theme} onToggleTheme={toggleTheme} />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/video/:id" element={<VideoPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
<Route path="/playlists/:id" element={<PlaylistPage />} />
|
<Route path="/video/:id" element={<VideoPage />} />
|
||||||
<Route path="/callback" element={<CallbackPage />} />
|
<Route element={<RequireAuth />}>
|
||||||
</Route>
|
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||||
</Routes>
|
<Route path="/playlists/:id" element={<PlaylistPage />} />
|
||||||
</BrowserRouter>
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
frontend/src/components/RequireAuth.tsx
Normal file
15
frontend/src/components/RequireAuth.tsx
Normal file
@@ -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 <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -20,10 +20,11 @@ export default function VideoPlayer({ storageType, storageKey }: VideoPlayerProp
|
|||||||
return <YouTubePlayer videoId={storageKey} />;
|
return <YouTubePlayer videoId={storageKey} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL || '/api';
|
||||||
const url =
|
const url =
|
||||||
storageType === 'external'
|
storageType === 'external'
|
||||||
? storageKey
|
? storageKey
|
||||||
: `${import.meta.env.VITE_API_URL}/stream/${storageKey}`;
|
: `${apiBase}/stream/${storageKey}`;
|
||||||
|
|
||||||
return <NativePlayer url={url} />;
|
return <NativePlayer url={url} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import type { User } from '../../hooks/useAuth';
|
import type { User } from '../../context/AuthContext';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
@@ -39,6 +39,11 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP
|
|||||||
Playlists
|
Playlists
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{user && (
|
||||||
|
<Link to="/admin" className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
|
||||||
|
admin
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right — thème + auth */}
|
{/* Right — thème + auth */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuthContext } from '../../context/AuthContext';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
@@ -8,7 +8,7 @@ interface LayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ theme, onToggleTheme }: LayoutProps) {
|
export default function Layout({ theme, onToggleTheme }: LayoutProps) {
|
||||||
const { user, loading, setUser } = useAuth();
|
const { user, loading, setUser } = useAuthContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-od-bg text-od-text">
|
<div className="min-h-screen bg-od-bg text-od-text">
|
||||||
|
|||||||
50
frontend/src/context/AuthContext.tsx
Normal file
50
frontend/src/context/AuthContext.tsx
Normal file
@@ -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<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
interface MeResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: { user: User };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
apiFetch<MeResponse>('/auth/me')
|
||||||
|
.then((res) => { if (!cancelled) setUser(res.data.user); })
|
||||||
|
.catch(() => { if (!cancelled) setUser(null); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, setUser }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthContext(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuthContext must be used inside AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
439
frontend/src/pages/AdminPage.tsx
Normal file
439
frontend/src/pages/AdminPage.tsx
Normal file
@@ -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<Tab>('videos');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-1 border-b border-od-border pb-4">
|
||||||
|
{(['videos', 'users', 'plans'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`rounded px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||||
|
tab === t
|
||||||
|
? 'bg-od-surface text-od-accent border border-od-accent'
|
||||||
|
: 'text-od-muted hover:text-od-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'videos' && <VideosTab />}
|
||||||
|
{tab === 'users' && <UsersTab />}
|
||||||
|
{tab === 'plans' && <PlansTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Videos tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function VideosTab() {
|
||||||
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* Formulaire création */}
|
||||||
|
<form onSubmit={handleCreate} className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
|
||||||
|
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Nouvelle vidéo</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={form.storageType}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, storageType: e.target.value }))}
|
||||||
|
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent"
|
||||||
|
>
|
||||||
|
<option value="youtube">YouTube</option>
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="s3">S3</option>
|
||||||
|
<option value="external">External</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={form.storageKey}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-od-muted">
|
||||||
|
Niveau requis
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.requiredLevel}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, requiredLevel: parseInt(e.target.value) || 0 }))}
|
||||||
|
className="w-16 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-od-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isPublished}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, isPublished: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Publié
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-od-crit">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="self-start rounded border border-od-accent px-4 py-1.5 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? '…' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{[...Array(3)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{videos.map((v) => (
|
||||||
|
<div key={v.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-od-text truncate">{v.title}</p>
|
||||||
|
<p className="font-mono text-xs text-od-muted">{v.storageType} · niveau {v.requiredLevel}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => togglePublish(v)}
|
||||||
|
className={`font-mono text-xs px-2 py-0.5 rounded border transition-colors ${
|
||||||
|
v.isPublished
|
||||||
|
? 'border-od-accent text-od-accent hover:bg-od-accent hover:text-od-bg'
|
||||||
|
: 'border-od-border text-od-muted hover:border-od-accent hover:text-od-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v.isPublished ? 'publié' : 'brouillon'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(v.id)}
|
||||||
|
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{videos.length === 0 && <p className="text-sm text-od-muted">Aucune vidéo.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UsersTab() {
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [assigning, setAssigning] = useState<string | null>(null);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
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 <div className="h-32 animate-pulse rounded border border-od-border" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} className="flex flex-col gap-2 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-od-text">{u.nickname}</p>
|
||||||
|
<p className="font-mono text-xs text-od-muted">{u.email ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{u.activeSubscription ? (
|
||||||
|
<span className="font-mono text-xs text-od-accent">
|
||||||
|
{u.activeSubscription.plan.name}
|
||||||
|
{u.activeSubscription.endsAt && ` · ${new Date(u.activeSubscription.endsAt).toLocaleDateString()}`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs text-od-muted">free</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedPlan[u.id] ?? ''}
|
||||||
|
onChange={(e) => setSelectedPlan((s) => ({ ...s, [u.id]: e.target.value }))}
|
||||||
|
className="flex-1 rounded border border-od-border bg-od-bg px-2 py-1 text-xs text-od-text outline-none focus:border-od-accent"
|
||||||
|
>
|
||||||
|
<option value="">— Assigner un plan —</option>
|
||||||
|
{plans.filter((p) => p.isActive).map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name} (niv. {p.level})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
disabled={!selectedPlan[u.id] || assigning === u.id}
|
||||||
|
onClick={() => assignPlan(u.id)}
|
||||||
|
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{assigning === u.id ? '…' : 'Assigner'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && <p className="text-sm text-od-muted">Aucun utilisateur.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plans tab ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PlansTab() {
|
||||||
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [form, setForm] = useState({ slug: '', name: '', level: 1, priceInCents: 0 });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 <div className="h-24 animate-pulse rounded border border-od-border" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
|
||||||
|
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Nouveau plan</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-od-muted">
|
||||||
|
Niveau
|
||||||
|
<input
|
||||||
|
type="number" min={1}
|
||||||
|
value={form.level}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, level: parseInt(e.target.value) || 1 }))}
|
||||||
|
className="w-16 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-od-muted">
|
||||||
|
Prix (centimes)
|
||||||
|
<input
|
||||||
|
type="number" min={0}
|
||||||
|
value={form.priceInCents}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, priceInCents: parseInt(e.target.value) || 0 }))}
|
||||||
|
className="w-24 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-od-crit">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit" disabled={saving}
|
||||||
|
className="self-start rounded border border-od-accent px-4 py-1.5 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? '…' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{plans.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-od-text">{p.name}</p>
|
||||||
|
<p className="font-mono text-xs text-od-muted">
|
||||||
|
{p.slug} · niv. {p.level} · {(p.priceInCents / 100).toFixed(2)} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(p)}
|
||||||
|
className={`font-mono text-xs px-2 py-0.5 rounded border transition-colors ${
|
||||||
|
p.isActive
|
||||||
|
? 'border-od-accent text-od-accent hover:bg-od-accent hover:text-od-bg'
|
||||||
|
: 'border-od-border text-od-muted hover:border-od-accent hover:text-od-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.isActive ? 'actif' : 'inactif'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{plans.length === 0 && <p className="text-sm text-od-muted">Aucun plan.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
const PROVIDERS = [
|
const PROVIDERS = [
|
||||||
@@ -11,6 +11,8 @@ const PROVIDERS = [
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const from = (location.state as { from?: Location })?.from?.pathname ?? '/';
|
||||||
const base = import.meta.env.VITE_SUPEROAUTH_URL;
|
const base = import.meta.env.VITE_SUPEROAUTH_URL;
|
||||||
const redirectUrl = encodeURIComponent(window.location.origin + '/callback');
|
const redirectUrl = encodeURIComponent(window.location.origin + '/callback');
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
navigate('/', { replace: true });
|
navigate(from, { replace: true });
|
||||||
} catch {
|
} catch {
|
||||||
setError('Email ou mot de passe incorrect.');
|
setError('Email ou mot de passe incorrect.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user