mirror of
https://github.com/myronblair/kino-app
synced 2026-07-27 21:18:43 -05:00
Add user management (CRUD + admin toggle), make Settings available to all users
- New /admin/users page: create/delete users, toggle admin role, guarded against self-demotion/self-deletion - Backend: GET/POST/PATCH/DELETE /api/admin/users, admin-only - Settings moved from /admin/settings to /settings (all logged-in users); the password-change section shows for everyone, integration/API-key sections only render (and only fetch) for admins, since those endpoints stay admin-gated - Navbar: Settings link now visible to all users; added Users nav link for admins
This commit is contained in:
+3
-1
@@ -24,6 +24,7 @@ import ShowDetail from "./pages/ShowDetail";
|
||||
import EpisodePlayer from "./pages/EpisodePlayer";
|
||||
import SonarrImport from "./pages/SonarrImport";
|
||||
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
|
||||
import Users from "./pages/Users";
|
||||
|
||||
const ProfileGate = ({ children }) => {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
@@ -89,9 +90,10 @@ function App() {
|
||||
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
||||
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
||||
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
|
||||
<Route path="/settings" element={<ProfileGate><Settings /></ProfileGate>} />
|
||||
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
|
||||
<Route path="/admin/users" element={<AdminGate><Users /></AdminGate>} />
|
||||
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
|
||||
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
|
||||
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
|
||||
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
|
||||
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
|
||||
|
||||
@@ -39,6 +39,7 @@ export const Navbar = () => {
|
||||
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">Shelf</NavLink>
|
||||
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Wishlist</NavLink>
|
||||
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
|
||||
{user.is_admin && <NavLink to="/admin/users" className={linkClass} data-testid="nav-users">Users</NavLink>}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
@@ -49,15 +50,13 @@ export const Navbar = () => {
|
||||
<Search size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
{user.is_admin && (
|
||||
<>
|
||||
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
|
||||
<Upload size={14} strokeWidth={1.5} /> Upload
|
||||
</button>
|
||||
<button onClick={() => nav("/admin/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
</button>
|
||||
</>
|
||||
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
|
||||
<Upload size={14} strokeWidth={1.5} /> Upload
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => nav("/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
</button>
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
|
||||
<button onClick={switchProfile} className="flex items-center gap-2 group" data-testid="nav-switch-profile" title="Switch profile">
|
||||
{active && (
|
||||
|
||||
@@ -88,9 +88,12 @@ export default function Admin() {
|
||||
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
|
||||
</Link>
|
||||
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
|
||||
<Link to="/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
</Link>
|
||||
<Link to="/admin/users" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-users-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Users
|
||||
</Link>
|
||||
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
|
||||
Review Requests
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, createContext, useContext } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, EyeOff, ListTodo, Link2, Unlink } from "lucide-react";
|
||||
|
||||
@@ -40,6 +41,8 @@ const Field = ({ label, k, type = "text", placeholder, mask }) => {
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = !!user?.is_admin;
|
||||
const [s, setS] = useState({
|
||||
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
|
||||
sonarr_url: "", sonarr_api_key: "",
|
||||
@@ -56,6 +59,7 @@ export default function Settings() {
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!isAdmin) return;
|
||||
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]);
|
||||
setS({
|
||||
tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "",
|
||||
@@ -66,7 +70,7 @@ export default function Settings() {
|
||||
});
|
||||
setInfo(data); setTraktStatus(ts);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault(); setSaving(true);
|
||||
@@ -123,11 +127,11 @@ export default function Settings() {
|
||||
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
||||
<div className="px-6 md:px-12 max-w-3xl mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">{isAdmin ? "Admin" : "Account"}</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
|
||||
|
||||
<form onSubmit={changePassword} className="mt-12 space-y-4 pb-10 border-b border-[#222]" data-testid="change-password-form">
|
||||
<h2 className="font-display text-2xl font-bold text-white">Admin Password</h2>
|
||||
<h2 className="font-display text-2xl font-bold text-white">Password</h2>
|
||||
<p className="text-sm text-[#8A8A8A]">Requires your current password. Changing it locks out anyone who only had the old one.</p>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Current password</span>
|
||||
@@ -169,6 +173,7 @@ export default function Settings() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
|
||||
{/* TMDB */}
|
||||
<section>
|
||||
@@ -267,6 +272,7 @@ export default function Settings() {
|
||||
{saving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trakt device-code modal */}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Trash2, UserPlus, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export default function Users() {
|
||||
const { user: me } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [form, setForm] = useState({ email: "", password: "", name: "", is_admin: false });
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/admin/users");
|
||||
setUsers(data);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const createUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post("/admin/users", form);
|
||||
toast.success("User created");
|
||||
setForm({ email: "", password: "", name: "", is_admin: false });
|
||||
setShowNew(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not create user");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAdmin = async (u) => {
|
||||
try {
|
||||
await api.patch(`/admin/users/${u.id}`, { is_admin: !u.is_admin });
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not update user");
|
||||
}
|
||||
};
|
||||
|
||||
const removeUser = async (u) => {
|
||||
if (!window.confirm(`Delete user ${u.email}? This also removes their profiles/watchlist.`)) return;
|
||||
try {
|
||||
await api.delete(`/admin/users/${u.id}`);
|
||||
toast.success("User deleted");
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not delete user");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="users-page">
|
||||
<div className="px-6 md:px-12 max-w-4xl mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Users</h1>
|
||||
|
||||
<button
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="mt-8 flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]"
|
||||
data-testid="new-user-button"
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={1.5} /> {showNew ? "Cancel" : "New User"}
|
||||
</button>
|
||||
|
||||
{showNew && (
|
||||
<form onSubmit={createUser} className="mt-6 border border-[#222] p-6 space-y-4" data-testid="new-user-form">
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Email</span>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Password (min 8 chars)</span>
|
||||
<div className="relative mt-2">
|
||||
<input type={showPw ? "text" : "password"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required
|
||||
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12" />
|
||||
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
|
||||
{showPw ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" checked={form.is_admin} onChange={(e) => setForm({ ...form, is_admin: e.target.checked })} />
|
||||
<span className="text-sm text-[#8A8A8A]">Admin access</span>
|
||||
</label>
|
||||
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]">
|
||||
{saving ? "Creating…" : "Create User"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-10 border border-[#222]">
|
||||
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
|
||||
<span className="col-span-4">Name</span>
|
||||
<span className="col-span-4">Email</span>
|
||||
<span className="col-span-2">Role</span>
|
||||
<span className="col-span-2 text-right">Actions</span>
|
||||
</div>
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`user-row-${u.id}`}>
|
||||
<span className="col-span-4 text-white truncate">{u.name}</span>
|
||||
<span className="col-span-4 text-[#8A8A8A] text-sm truncate">{u.email}</span>
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
onClick={() => toggleAdmin(u)}
|
||||
disabled={u.id === me?.id && u.is_admin}
|
||||
title={u.id === me?.id && u.is_admin ? "Can't remove your own admin access" : "Toggle role"}
|
||||
className={`text-[10px] uppercase tracking-[0.2em] px-2 py-1 border disabled:opacity-40 disabled:cursor-not-allowed ${u.is_admin ? "border-[#D9381E] text-[#D9381E]" : "border-[#222] text-[#8A8A8A] hover:border-white hover:text-white"}`}
|
||||
data-testid={`toggle-admin-${u.id}`}
|
||||
>
|
||||
{u.is_admin ? "Admin" : "User"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end">
|
||||
<button
|
||||
onClick={() => removeUser(u)}
|
||||
disabled={u.id === me?.id}
|
||||
title={u.id === me?.id ? "Can't delete your own account" : "Delete"}
|
||||
className="text-[#8A8A8A] hover:text-[#fca5a5] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
data-testid={`delete-user-${u.id}`}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user