"use client"; import { canAccess, type FeatureAccess } from "../lib/features"; import PageHeading from "../ui/PageHeading"; import MonthlyRecapPreference from "./MonthlyRecapPreference"; import NewsletterPreference from "./NewsletterPreference"; import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react"; import { useRouter } from "next/navigation"; import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth"; type ProfileInfo = { features?: FeatureAccess; username: string; email?: string | null; role: string; auth_provider: string; password_change_supported?: boolean; password_provider?: "local" | "jellyfin" | null; }; type ActivityEntry = { ip: string; user_agent: string; first_seen_at: string; last_seen_at: string; }; type ProfileResponse = { user: ProfileInfo; stats?: { total: number; ready: number; in_progress: number }; activity?: { recent: ActivityEntry[] }; }; type Notice = { tone: "status" | "error"; message: string } | null; type ProfileTab = "overview" | "security" | "activity"; const TABS: { key: ProfileTab; label: string }[] = [ { key: "overview", label: "Account" }, { key: "security", label: "Security" }, { key: "activity", label: "Activity" }, ]; const normalizeTab = (value: string | null): ProfileTab => value === "security" || value === "activity" ? value : "overview"; const formatDate = (value?: string) => { if (!value) return "Not recorded"; const date = new Date(value); return Number.isNaN(date.valueOf()) ? "Not recorded" : date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); }; const deviceName = (agent: string) => { const value = (agent || "").toLowerCase(); const browser = value.includes("edg/") ? "Edge" : value.includes("firefox/") || value.includes("fxios/") ? "Firefox" : value.includes("chrome/") || value.includes("crios/") ? "Chrome" : value.includes("safari/") ? "Safari" : "Browser"; const device = /iphone|ipad/.test(value) ? "iOS" : value.includes("android") ? "Android" : value.includes("windows") ? "Windows" : value.includes("macintosh") ? "Mac" : value.includes("linux") ? "Linux" : ""; return device ? `${browser} on ${device}` : browser; }; const responseMessage = async (response: Response, fallback: string) => { const data = await response.json().catch(() => null); return typeof data?.detail === "string" && data.detail.trim() ? data.detail : fallback; }; export default function ProfilePage() { const router = useRouter(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(""); const [activeTab, setActiveTab] = useState("overview"); const [email, setEmail] = useState(""); const [emailSaving, setEmailSaving] = useState(false); const [emailNotice, setEmailNotice] = useState(null); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordSaving, setPasswordSaving] = useState(false); const [passwordNotice, setPasswordNotice] = useState(null); const [showAllActivity, setShowAllActivity] = useState(false); const loadProfile = useCallback(async () => { if (!getToken()) { router.replace("/login?next=%2Fprofile"); return; } setLoading(true); setLoadError(""); try { const response = await authFetch(`${getApiBase()}/auth/profile`); if (response.status === 401) { clearToken(); router.replace("/login?next=%2Fprofile"); return; } if (!response.ok) throw new Error("Could not load your profile. Please try again."); const profile = (await response.json()) as ProfileResponse; setData(profile); setEmail(profile.user.email ?? ""); } catch { setLoadError("Could not load your profile. Please try again."); } finally { setLoading(false); } }, [router]); useEffect(() => { void loadProfile(); }, [loadProfile]); useEffect(() => { const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get("tab"))); syncTab(); window.addEventListener("popstate", syncTab); return () => window.removeEventListener("popstate", syncTab); }, []); const selectTab = (tab: ProfileTab) => { setActiveTab(tab); router.replace(tab === "overview" ? "/profile" : `/profile?tab=${tab}`, { scroll: false }); }; const tabKeyDown = (event: KeyboardEvent, index: number) => { let next = index; if (event.key === "ArrowRight") next = (index + 1) % TABS.length; else if (event.key === "ArrowLeft") next = (index + TABS.length - 1) % TABS.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = TABS.length - 1; else return; event.preventDefault(); selectTab(TABS[next].key); document.getElementById(`profile-tab-${TABS[next].key}`)?.focus(); }; const saveEmail = async (event: FormEvent) => { event.preventDefault(); if (emailSaving) return; setEmailSaving(true); setEmailNotice(null); try { const response = await authFetch(`${getApiBase()}/auth/profile/email`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: email.trim() || null }), }); if (response.status === 401) { clearToken(); router.replace("/login?next=%2Fprofile"); return; } if (!response.ok) throw new Error(await responseMessage(response, "Could not save your email. Please try again.")); const result = await response.json(); const saved = typeof result.email === "string" ? result.email : ""; setData((current) => (current ? { ...current, user: { ...current.user, email: saved || null } } : current)); setEmail(saved); setEmailNotice({ tone: "status", message: saved ? "Email saved." : "Email removed." }); } catch (error) { setEmailNotice({ tone: "error", message: error instanceof Error ? error.message : "Could not save your email." }); } finally { setEmailSaving(false); } }; const savePassword = async (event: FormEvent) => { event.preventDefault(); if (passwordSaving) return; setPasswordNotice(null); if (newPassword.trim().length < 8) { setPasswordNotice({ tone: "error", message: "Use at least 8 characters for your new password." }); return; } if (newPassword !== confirmPassword) { setPasswordNotice({ tone: "error", message: "The new passwords do not match." }); return; } setPasswordSaving(true); try { const response = await authFetch(`${getApiBase()}/auth/password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), }); if (!response.ok) throw new Error(await responseMessage(response, "Could not change your password. Please try again.")); const result = await response.json(); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); setPasswordNotice({ tone: "status", message: result.provider === "jellyfin" ? "Password updated for Grizzlyflix and Magent. Seerr uses the same password." : "Password updated.", }); } catch (error) { setPasswordNotice({ tone: "error", message: error instanceof Error ? error.message : "Could not change your password.", }); } finally { setPasswordSaving(false); } }; const user = data?.user; const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local"); const canChangePassword = user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? ""); const emailChanged = email.trim() !== (user?.email ?? ""); const recent = data?.activity?.recent ?? []; const notice = (value: Notice) => value && (

{value.message}

); return (
{user.username} {user.role === "admin" ? "Administrator" : "Member"}
) } /> {loading ? (

Loading your profile…

) : loadError ? (

{loadError}

) : ( user && ( <>
{TABS.map((tab, index) => ( ))}
) )}
); }