From f7626ced2edeb685fc2d1462c449fd89691e321c Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 26 Jul 2026 19:40:12 -0500 Subject: [PATCH] =?UTF-8?q?Merge=20watch=20profiles=20into=20user=20accoun?= =?UTF-8?q?ts=20=E2=80=94=20one=20profile=20per=20login,=20no=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the "Who's Watching" sub-profile picker entirely: ProfileProvider now auto-resolves the single profile tied to the account immediately after login - Backend: removed POST/DELETE /profiles (no more creating/deleting extra profiles — lifecycle is entirely driven by user create/delete, already wired in the admin Users CRUD); kept GET (internal) and PATCH (edit your own profile's avatar/kids-mode/rating cap) - Deleted ProfileSelect page and the now-unused ProtectedRoute component - Navbar: removed the "switch profile" action, account avatar is now just a static display - api.js: dropped the now-dead X-Profile-Id header logic (backend already falls back to the account's single profile when the header is absent) --- backend/models.py | 9 +- backend/server.py | 30 +--- frontend/src/App.js | 11 +- frontend/src/components/Navbar.jsx | 31 ++-- frontend/src/components/ProtectedRoute.jsx | 18 --- frontend/src/lib/api.js | 6 - frontend/src/lib/profile.jsx | 22 +-- frontend/src/pages/ProfileSelect.jsx | 157 --------------------- 8 files changed, 22 insertions(+), 262 deletions(-) delete mode 100644 frontend/src/components/ProtectedRoute.jsx delete mode 100644 frontend/src/pages/ProfileSelect.jsx diff --git a/backend/models.py b/backend/models.py index 7e110bd..10baa38 100644 --- a/backend/models.py +++ b/backend/models.py @@ -47,17 +47,10 @@ class AdminUserUpdate(BaseModel): password: Optional[str] = None -# ---------- Profiles ("Who's Watching") ---------- +# ---------- Profile (1:1 with account) ---------- RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"] -class ProfileCreate(BaseModel): - name: str - avatar_color: str = "#D9381E" - is_kids: bool = False - max_rating: str = "NR" # one of RATING_ORDER - - class ProfileUpdate(BaseModel): name: Optional[str] = None avatar_color: Optional[str] = None diff --git a/backend/server.py b/backend/server.py index e57b5c0..06bab66 100644 --- a/backend/server.py +++ b/backend/server.py @@ -17,7 +17,7 @@ from motor.motor_asyncio import AsyncIOMotorClient from models import ( UserCreate, UserLogin, UserPublic, TokenResponse, PasswordChange, AdminUserCreate, AdminUserUpdate, - Profile, ProfileCreate, ProfileUpdate, RATING_ORDER, + Profile, ProfileUpdate, RATING_ORDER, Movie, MovieCreate, MovieUpdate, Subtitle, WatchlistItem, ProgressUpsert, @@ -308,25 +308,17 @@ async def admin_delete_user(user_id: str, user: dict = Depends(require_admin)): return {"ok": True} -# ============ PROFILES ============ +# ============ PROFILE (1:1 with account — no separate sub-profile management) ============ @api.get("/profiles", response_model=List[Profile]) async def list_profiles(user: dict = Depends(get_current_user)): - docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(20) + """Always exactly one profile per account; get_active_profile auto-creates it if missing.""" + docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(1) return docs -@api.post("/profiles", response_model=Profile) -async def create_profile(payload: ProfileCreate, user: dict = Depends(get_current_user)): - count = await db.profiles.count_documents({"user_id": user["id"]}) - if count >= 5: - raise HTTPException(status_code=400, detail="Max 5 profiles per account") - prof = Profile(user_id=user["id"], **payload.model_dump()).model_dump() - await db.profiles.insert_one(prof) - return _strip(prof) - - @api.patch("/profiles/{pid}", response_model=Profile) async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(get_current_user)): + """Edit your own single profile (avatar color, kids mode, rating cap) — not a multi-profile switcher.""" updates = {k: v for k, v in payload.model_dump().items() if v is not None} if not updates: raise HTTPException(status_code=400, detail="No fields to update") @@ -340,18 +332,6 @@ async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends( return res -@api.delete("/profiles/{pid}") -async def delete_profile(pid: str, user: dict = Depends(get_current_user)): - if await db.profiles.count_documents({"user_id": user["id"]}) <= 1: - raise HTTPException(status_code=400, detail="Cannot delete the only profile") - res = await db.profiles.delete_one({"id": pid, "user_id": user["id"]}) - if res.deleted_count == 0: - raise HTTPException(status_code=404, detail="Profile not found") - await db.watchlist.delete_many({"profile_id": pid}) - await db.progress.delete_many({"profile_id": pid}) - return {"ok": True} - - # ============ MOVIES ============ def _movie_filter_for_profile(profile: dict) -> dict: cap = profile.get("max_rating", "NR") diff --git a/frontend/src/App.js b/frontend/src/App.js index 1fdd8af..5b1c7c7 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -2,7 +2,6 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route import { Toaster } from "sonner"; import { AuthProvider, useAuth } from "./lib/auth"; import { ProfileProvider, useProfile } from "./lib/profile"; -import ProtectedRoute from "./components/ProtectedRoute"; import Navbar from "./components/Navbar"; import GrainOverlay from "./components/GrainOverlay"; import ServiceStatus from "./components/ServiceStatus"; @@ -17,7 +16,6 @@ import Admin from "./pages/Admin"; import AdminUpload from "./pages/AdminUpload"; import Settings from "./pages/Settings"; import RadarrImport from "./pages/RadarrImport"; -import ProfileSelect from "./pages/ProfileSelect"; import TranscodeQueue from "./pages/TranscodeQueue"; import Shows from "./pages/Shows"; import ShowDetail from "./pages/ShowDetail"; @@ -30,11 +28,10 @@ const ProfileGate = ({ children }) => { const { user, loading: authLoading } = useAuth(); const { active, loading: profLoading } = useProfile(); const loc = useLocation(); - if (authLoading || profLoading) { + if (authLoading || profLoading || (user && !active)) { return
Loading…
; } if (!user) return ; - if (!active) return ; return children; }; @@ -50,7 +47,7 @@ const Shell = ({ children }) => { const { user } = useAuth(); const { active } = useProfile(); const loc = useLocation(); - const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register" || loc.pathname === "/profile"; + const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register"; return ( <> {!hideChrome && user && active && } @@ -63,10 +60,9 @@ const Shell = ({ children }) => { const RootRedirect = () => { const { user, loading } = useAuth(); - const { active, loading: pl } = useProfile(); + const { loading: pl } = useProfile(); if (loading || pl) return
Loading…
; if (!user) return ; - if (!active) return ; return ; }; @@ -81,7 +77,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index dc996a1..4bf5a94 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -1,12 +1,12 @@ import { Link, NavLink, useNavigate } from "react-router-dom"; import { useAuth } from "../lib/auth"; import { useProfile } from "../lib/profile"; -import { Search, LogOut, Upload, Users, Settings as SettingsIcon } from "lucide-react"; +import { Search, LogOut, Upload, Settings as SettingsIcon } from "lucide-react"; import { useState, useEffect } from "react"; export const Navbar = () => { const { user, logout } = useAuth(); - const { active, clearActive } = useProfile(); + const { active } = useProfile(); const nav = useNavigate(); const [scrolled, setScrolled] = useState(false); @@ -19,11 +19,6 @@ export const Navbar = () => { const linkClass = ({ isActive }) => `text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`; - const switchProfile = () => { - clearActive(); - nav("/profile"); - }; - return (
@@ -58,20 +53,14 @@ export const Navbar = () => { Settings
- + {active && ( +
+ {active.name} +
+ {active.name?.[0]?.toUpperCase() || "U"} +
+
+ )} diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx deleted file mode 100644 index 1b05cef..0000000 --- a/frontend/src/components/ProtectedRoute.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Navigate } from "react-router-dom"; -import { useAuth } from "../lib/auth"; - -export const ProtectedRoute = ({ children, adminOnly = false }) => { - const { user, loading } = useAuth(); - if (loading) { - return ( -
- Loading… -
- ); - } - if (!user) return ; - if (adminOnly && !user.is_admin) return ; - return children; -}; - -export default ProtectedRoute; diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 27523a9..6c2d436 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -8,12 +8,6 @@ const instance = axios.create({ baseURL: API }); instance.interceptors.request.use((config) => { const token = localStorage.getItem("kino_token"); if (token) config.headers.Authorization = `Bearer ${token}`; - // Active profile id (set by ProfileProvider via localStorage) - const userId = JSON.parse(localStorage.getItem("kino_user_id") || "null"); - if (userId) { - const pid = localStorage.getItem(`kino_profile_${userId}`); - if (pid) config.headers["X-Profile-Id"] = pid; - } return config; }); diff --git a/frontend/src/lib/profile.jsx b/frontend/src/lib/profile.jsx index 211b995..9546bb0 100644 --- a/frontend/src/lib/profile.jsx +++ b/frontend/src/lib/profile.jsx @@ -6,19 +6,15 @@ const ProfileCtx = createContext(null); export const ProfileProvider = ({ children }) => { const { user } = useAuth(); - const [profiles, setProfiles] = useState([]); const [active, setActive] = useState(null); const [loading, setLoading] = useState(false); const refresh = useCallback(async () => { - if (!user) { setProfiles([]); setActive(null); return; } + if (!user) { setActive(null); return; } setLoading(true); try { const { data } = await api.get("/profiles"); - setProfiles(data); - const stored = localStorage.getItem(`kino_profile_${user.id}`); - const found = data.find((p) => p.id === stored); - setActive(found || null); + setActive(data[0] || null); } finally { setLoading(false); } @@ -26,20 +22,8 @@ export const ProfileProvider = ({ children }) => { useEffect(() => { refresh(); }, [refresh]); - const switchTo = (p) => { - if (!user) return; - localStorage.setItem(`kino_profile_${user.id}`, p.id); - setActive(p); - }; - - const clearActive = () => { - if (!user) return; - localStorage.removeItem(`kino_profile_${user.id}`); - setActive(null); - }; - return ( - + {children} ); diff --git a/frontend/src/pages/ProfileSelect.jsx b/frontend/src/pages/ProfileSelect.jsx deleted file mode 100644 index d09dd5c..0000000 --- a/frontend/src/pages/ProfileSelect.jsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import api from "../lib/api"; -import { useProfile } from "../lib/profile"; -import { useAuth } from "../lib/auth"; -import { Plus, Pencil, Check, X, Trash2 } from "lucide-react"; -import { toast } from "sonner"; - -const COLORS = ["#D9381E", "#EAB308", "#22C55E", "#3B82F6", "#A855F7", "#EC4899"]; - -export default function ProfileSelect() { - const { user, logout } = useAuth(); - const { profiles, refresh, switchTo } = useProfile(); - const nav = useNavigate(); - const [editing, setEditing] = useState(false); - const [creating, setCreating] = useState(false); - const [draft, setDraft] = useState({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" }); - - useEffect(() => { refresh(); }, [refresh]); - - const choose = (p) => { - switchTo(p); - nav("/browse", { replace: true }); - }; - - const submitNew = async (e) => { - e.preventDefault(); - if (!draft.name.trim()) return; - try { - await api.post("/profiles", draft); - toast.success("Profile created"); - setCreating(false); - setDraft({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" }); - refresh(); - } catch (err) { - toast.error(err.response?.data?.detail || "Could not create"); - } - }; - - const remove = async (id) => { - if (!window.confirm("Delete this profile? Watchlist & history will be removed.")) return; - try { await api.delete(`/profiles/${id}`); refresh(); } catch (err) { - toast.error(err.response?.data?.detail || "Could not delete"); - } - }; - - return ( -
- Welcome back, {user?.name} -

- Choose a viewer -

- -
- {profiles.map((p) => ( -
- - {p.name} -
- {p.is_kids && Kids} - {p.max_rating !== "NR" && · Up to {p.max_rating}} -
- {editing && ( - - )} -
- ))} - - {profiles.length < 5 && ( - - )} -
- -
- - -
- - {creating && ( -
-
setCreating(false)} /> -
- -

New profile

- - - -
- Color -
- {COLORS.map((c) => ( -
-
- - - - - - -
-
- )} -
- ); -}