Merge watch profiles into user accounts — one profile per login, no picker

- 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)
This commit is contained in:
Myron Blair
2026-07-26 19:40:12 -05:00
parent 5b191da471
commit f7626ced2e
8 changed files with 22 additions and 262 deletions
+1 -8
View File
@@ -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
+5 -25
View File
@@ -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")
+3 -8
View File
@@ -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 <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
}
if (!user) return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
if (!active) return <Navigate to="/profile" replace />;
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 && <Navbar />}
@@ -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 <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
if (!user) return <Navigate to="/login" replace />;
if (!active) return <Navigate to="/profile" replace />;
return <Navigate to="/browse" replace />;
};
@@ -81,7 +77,6 @@ function App() {
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
+10 -21
View File
@@ -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 (
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled ? "glass border-b" : ""}`} data-testid="main-navbar">
<div className="px-6 md:px-12 py-4 flex items-center justify-between">
@@ -58,20 +53,14 @@ export const Navbar = () => {
<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 && (
<>
<div className="hidden sm:flex flex-col items-end leading-tight">
<span className="text-xs text-white">{active.name}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] group-hover:text-white transition-colors">switch</span>
</div>
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
{active.name?.[0]?.toUpperCase() || "U"}
</div>
</>
)}
{!active && <Users size={18} strokeWidth={1.5} className="text-[#8A8A8A]" />}
</button>
{active && (
<div className="flex items-center gap-2" data-testid="account-avatar">
<span className="hidden sm:inline text-xs text-white">{active.name}</span>
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
{active.name?.[0]?.toUpperCase() || "U"}
</div>
</div>
)}
<button onClick={() => { logout(); nav("/login"); }} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
<LogOut size={16} strokeWidth={1.5} />
</button>
@@ -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 (
<div className="min-h-screen flex items-center justify-center text-[#8A8A8A]" data-testid="auth-loading">
Loading
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
if (adminOnly && !user.is_admin) return <Navigate to="/browse" replace />;
return children;
};
export default ProtectedRoute;
-6
View File
@@ -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;
});
+3 -19
View File
@@ -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 (
<ProfileCtx.Provider value={{ profiles, active, loading, refresh, switchTo, clearActive }}>
<ProfileCtx.Provider value={{ active, loading, refresh }}>
{children}
</ProfileCtx.Provider>
);
-157
View File
@@ -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 (
<div className="min-h-screen bg-[#050505] flex flex-col items-center justify-center px-6 py-16" data-testid="profile-select-page">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-6">Welcome back, {user?.name}</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white text-center">
Choose a viewer
</h1>
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
{profiles.map((p) => (
<div key={p.id} className="flex flex-col items-center gap-3 group" data-testid={`profile-${p.id}`}>
<button
onClick={() => editing ? null : choose(p)}
className="w-28 h-28 md:w-36 md:h-36 flex items-center justify-center text-3xl font-bold text-white transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
style={{ backgroundColor: p.avatar_color }}
data-testid={`select-profile-${p.id}`}
>
{p.name?.[0]?.toUpperCase() || "?"}
</button>
<span className="font-display text-lg text-white">{p.name}</span>
<div className="flex gap-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{p.is_kids && <span>Kids</span>}
{p.max_rating !== "NR" && <span>· Up to {p.max_rating}</span>}
</div>
{editing && (
<button onClick={() => remove(p.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors mt-1" data-testid={`delete-profile-${p.id}`}>
<Trash2 size={14} strokeWidth={1.5} />
</button>
)}
</div>
))}
{profiles.length < 5 && (
<button
onClick={() => setCreating(true)}
className="w-28 h-28 md:w-36 md:h-36 flex flex-col items-center justify-center border border-dashed border-[#333] hover:border-[#D9381E] text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="add-profile-button"
>
<Plus size={32} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em] mt-2">Add</span>
</button>
)}
</div>
<div className="mt-16 flex gap-4">
<button onClick={() => setEditing(!editing)}
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300"
data-testid="manage-profiles-button">
{editing ? <Check size={14} strokeWidth={1.5} /> : <Pencil size={14} strokeWidth={1.5} />}
{editing ? "Done" : "Manage Profiles"}
</button>
<button onClick={() => { logout(); nav("/login"); }}
className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] transition-colors duration-300"
data-testid="profile-logout-button">
Sign out
</button>
</div>
{creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="new-profile-modal">
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setCreating(false)} />
<form onSubmit={submitNew} className="relative bg-[#0F0F0F] border border-[#222] p-8 w-full max-w-md mx-4 fade-up">
<button type="button" onClick={() => setCreating(false)} className="absolute top-4 right-4 text-[#8A8A8A] hover:text-white"><X size={18} /></button>
<h2 className="font-display text-3xl font-bold tracking-tight text-white">New profile</h2>
<label className="block mt-6">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
<input value={draft.name} onChange={(e) => setDraft({ ...draft, 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"
data-testid="new-profile-name" />
</label>
<div className="mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Color</span>
<div className="mt-2 flex gap-2">
{COLORS.map((c) => (
<button type="button" key={c} onClick={() => setDraft({ ...draft, avatar_color: c })}
className={`w-10 h-10 ${draft.avatar_color === c ? "ring-2 ring-white" : ""}`}
style={{ backgroundColor: c }} aria-label={`Color ${c}`}
data-testid={`color-${c.replace('#', '')}`} />
))}
</div>
</div>
<label className="flex items-center gap-3 mt-5 cursor-pointer">
<input type="checkbox" checked={draft.is_kids}
onChange={(e) => setDraft({ ...draft, is_kids: e.target.checked, max_rating: e.target.checked ? "PG" : draft.max_rating })}
className="accent-[#D9381E]" data-testid="new-profile-kids" />
<span className="text-sm text-[#C8C8C8]">Kids profile (limits content to PG)</span>
</label>
<label className="block mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Max rating</span>
<select value={draft.max_rating} onChange={(e) => setDraft({ ...draft, max_rating: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] text-white px-4 py-3 focus:border-[#D9381E] focus:outline-none"
data-testid="new-profile-rating">
<option value="G">G</option>
<option value="PG">PG</option>
<option value="PG-13">PG-13</option>
<option value="R">R</option>
<option value="NR">No restriction</option>
</select>
</label>
<button type="submit" className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] text-white py-3 text-sm uppercase tracking-[0.2em]"
data-testid="new-profile-submit">Create profile</button>
</form>
</div>
)}
</div>
);
}