add auth
This commit is contained in:
@@ -1,13 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route, NavLink, Navigate, useNavigate } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||
import { api, type Library } from "./api/client";
|
||||
import { AuthProvider } from "./auth/AuthContext";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import BrowserPage from "./pages/BrowserPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import SearchPage from "./pages/SearchPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
|
||||
<span style={{ color: "var(--text-secondary)" }}>Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [dark, setDark] = useState(
|
||||
() => document.documentElement.getAttribute("data-theme") === "dark"
|
||||
@@ -22,9 +43,12 @@ function useTheme() {
|
||||
}
|
||||
|
||||
function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void; dark: boolean; onClose?: () => void }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { data: libraries = [] } = useQuery<Library[]>({
|
||||
queryKey: ["libraries"],
|
||||
queryFn: api.libraries.list,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const linkStyle = ({ isActive }: { isActive: boolean }) => ({
|
||||
@@ -37,6 +61,11 @@ function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void;
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
width: 220,
|
||||
@@ -76,12 +105,24 @@ function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void;
|
||||
>
|
||||
{dark ? "☀ Light mode" : "☾ Dark mode"}
|
||||
</button>
|
||||
{user && (
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)", padding: "4px 12px 0" }}>
|
||||
{user.username}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{ textAlign: "left", border: "none", background: "transparent", padding: "6px 12px", color: "var(--danger)", borderRadius: 4 }}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { dark, toggle } = useTheme();
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -97,6 +138,15 @@ function AppShell() {
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}>
|
||||
{/* Mobile hamburger button */}
|
||||
@@ -129,10 +179,11 @@ function AppShell() {
|
||||
|
||||
<main style={{ flex: 1, overflow: "auto", background: "var(--bg)", paddingTop: isMobile ? 48 : 0 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<SearchPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/library/:libraryId" element={<BrowserPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
|
||||
<Route path="/search" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
|
||||
<Route path="/library/:libraryId" element={<ProtectedRoute><BrowserPage /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
@@ -142,9 +193,11 @@ function AppShell() {
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const BASE = "/api";
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("token");
|
||||
}
|
||||
|
||||
export interface Library {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -42,11 +46,28 @@ export interface BrowseResult {
|
||||
entries: BrowseEntry[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
...init,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
@@ -56,6 +77,23 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ access_token: string; token_type: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
me: () => request<AuthUser>("/auth/me"),
|
||||
listUsers: () => request<AuthUser[]>("/auth/users"),
|
||||
createUser: (username: string, password: string) =>
|
||||
request<AuthUser>("/auth/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
deleteUser: (id: number) =>
|
||||
request<void>(`/auth/users/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
|
||||
libraries: {
|
||||
list: () => request<Library[]>("/libraries"),
|
||||
create: (name: string, path: string) =>
|
||||
@@ -77,8 +115,16 @@ export const api = {
|
||||
|
||||
media: {
|
||||
get: (id: number) => request<MediaItem>(`/media/${id}`),
|
||||
fileUrl: (id: number) => `${BASE}/media/${id}/file`,
|
||||
thumbnailUrl: (id: number) => `${BASE}/media/${id}/thumbnail`,
|
||||
fileUrl: (id: number) => {
|
||||
const token = getToken();
|
||||
const base = `${BASE}/media/${id}/file`;
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
},
|
||||
thumbnailUrl: (id: number) => {
|
||||
const token = getToken();
|
||||
const base = `${BASE}/media/${id}/thumbnail`;
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
},
|
||||
setTags: (id: number, tagIds: number[]) =>
|
||||
request<MediaItem>(`/media/${id}/tags`, {
|
||||
method: "PUT",
|
||||
|
||||
86
frontend/src/auth/AuthContext.tsx
Normal file
86
frontend/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { AuthContext, type AuthUser } from "./AuthTypes";
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function fetchMe(token: string) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/me`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem("token"));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function validateToken() {
|
||||
if (!token) return;
|
||||
const u = await fetchMe(token);
|
||||
if (cancelled) return;
|
||||
if (u) {
|
||||
setUser(u);
|
||||
} else {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
}
|
||||
}
|
||||
|
||||
validateToken().finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const newToken = data.access_token;
|
||||
localStorage.setItem("token", newToken);
|
||||
setToken(newToken);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
19
frontend/src/auth/AuthTypes.ts
Normal file
19
frontend/src/auth/AuthTypes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthState | null>(null);
|
||||
10
frontend/src/auth/useAuth.ts
Normal file
10
frontend/src/auth/useAuth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext, type AuthState } from "./AuthTypes";
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
100
frontend/src/pages/LoginPage.tsx
Normal file
100
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (isAuthenticated) {
|
||||
navigate("/", { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100vh",
|
||||
background: "var(--bg)",
|
||||
}}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
width: 320,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: 32,
|
||||
borderRadius: 8,
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: 24, color: "var(--text)", textAlign: "center" }}>
|
||||
MediaLore
|
||||
</h1>
|
||||
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "var(--danger)", margin: 0, fontSize: 13 }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
padding: "10px 0",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Library } from "../api/client";
|
||||
import { api, type Library, type AuthUser } from "../api/client";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
|
||||
function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) {
|
||||
const qc = useQueryClient();
|
||||
@@ -48,6 +49,91 @@ function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) =>
|
||||
);
|
||||
}
|
||||
|
||||
function UserManagement() {
|
||||
const qc = useQueryClient();
|
||||
const { user: currentUser } = useAuth();
|
||||
const { data: users = [] } = useQuery<AuthUser[]>({
|
||||
queryKey: ["users"],
|
||||
queryFn: api.auth.listUsers,
|
||||
});
|
||||
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [userError, setUserError] = useState("");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => api.auth.createUser(newUsername, newPassword),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["users"] });
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
setUserError("");
|
||||
},
|
||||
onError: (e: Error) => setUserError(e.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.auth.deleteUser(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||
onError: (e: Error) => setUserError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: "var(--text)", marginTop: 32 }}>Users</h2>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); createMutation.mutate(); }}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
|
||||
>
|
||||
<input
|
||||
placeholder="Username"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{userError && <p style={{ color: "var(--danger)", margin: 0, fontSize: 13 }}>{userError}</p>}
|
||||
<button type="submit" disabled={createMutation.isPending} style={{ background: "var(--accent)", color: "#fff", border: "none" }}>
|
||||
{createMutation.isPending ? "Creating…" : "Add User"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{users.map((u) => (
|
||||
<li key={u.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text)" }}>{u.username}</strong>
|
||||
{u.is_admin && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: "var(--accent)", fontWeight: 600 }}>
|
||||
ADMIN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{u.id !== currentUser?.id && (
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(u.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
style={{ color: "var(--danger)", background: "transparent", border: "none" }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: libraries = [] } = useQuery<Library[]>({
|
||||
@@ -97,6 +183,8 @@ export default function SettingsPage() {
|
||||
<LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<UserManagement />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user