'use client' 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 = { 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'); return } setLoading(true) setLoadError('') try { const response = await authFetch(`${getApiBase()}/auth/profile`) if (response.status === 401) { clearToken(); router.replace('/login'); 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'); 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 (
YOUR ACCOUNT

My profile

{user &&
{user.username}{user.role === 'admin' ? 'Administrator' : 'Member'}
}
{loading ?

Loading your profile…

: loadError ? (

{loadError}

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