'use client' import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' type ProfileInfo = { username: string role: string auth_provider: string invite_management_enabled?: boolean } type ProfileStats = { total: number ready: number pending: number in_progress: number declined: number working: number partial: number approved: number last_request_at?: string | null share: number global_total: number most_active_user?: { username: string; total: number } | null } type ActivityEntry = { ip: string user_agent: string first_seen_at: string last_seen_at: string hit_count: number } type ProfileActivity = { last_ip?: string | null last_user_agent?: string | null last_seen_at?: string | null device_count: number recent: ActivityEntry[] } type ProfileResponse = { user: ProfileInfo stats: ProfileStats activity: ProfileActivity } type OwnedInvite = { id: number code: string label?: string | null description?: string | null max_uses?: number | null use_count: number remaining_uses?: number | null enabled: boolean expires_at?: string | null is_expired?: boolean is_usable?: boolean created_at?: string | null updated_at?: string | null } type OwnedInvitesResponse = { invites?: OwnedInvite[] count?: number invite_access?: { enabled?: boolean managed_by_master?: boolean } master_invite?: { id: number code: string label?: string | null description?: string | null max_uses?: number | null enabled?: boolean expires_at?: string | null is_usable?: boolean } | null } type OwnedInviteForm = { code: string label: string description: string max_uses: string expires_at: string enabled: boolean } type ProfileTab = 'overview' | 'activity' | 'invites' | 'security' const defaultOwnedInviteForm = (): OwnedInviteForm => ({ code: '', label: '', description: '', max_uses: '', expires_at: '', enabled: true, }) const formatDate = (value?: string | null) => { if (!value) return 'Never' const date = new Date(value) if (Number.isNaN(date.valueOf())) return value return date.toLocaleString() } const parseBrowser = (agent?: string | null) => { if (!agent) return 'Unknown' const value = agent.toLowerCase() if (value.includes('edg/')) return 'Edge' if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome' if (value.includes('firefox/')) return 'Firefox' if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari' return 'Unknown' } export default function ProfilePage() { const router = useRouter() const [profile, setProfile] = useState(null) const [stats, setStats] = useState(null) const [activity, setActivity] = useState(null) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [status, setStatus] = useState(null) const [inviteStatus, setInviteStatus] = useState(null) const [inviteError, setInviteError] = useState(null) const [invites, setInvites] = useState([]) const [inviteSaving, setInviteSaving] = useState(false) const [inviteEditingId, setInviteEditingId] = useState(null) const [inviteForm, setInviteForm] = useState(defaultOwnedInviteForm()) const [activeTab, setActiveTab] = useState('overview') const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false) const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false) const [masterInviteTemplate, setMasterInviteTemplate] = useState(null) const [loading, setLoading] = useState(true) const signupBaseUrl = useMemo(() => { if (typeof window === 'undefined') return '/signup' return `${window.location.origin}/signup` }, []) useEffect(() => { if (!getToken()) { router.push('/login') return } const load = async () => { try { const baseUrl = getApiBase() const [profileResponse, invitesResponse] = await Promise.all([ authFetch(`${baseUrl}/auth/profile`), authFetch(`${baseUrl}/auth/profile/invites`), ]) if (!profileResponse.ok || !invitesResponse.ok) { clearToken() router.push('/login') return } const [data, inviteData] = (await Promise.all([ profileResponse.json(), invitesResponse.json(), ])) as [ProfileResponse, OwnedInvitesResponse] const user = data?.user ?? {} setProfile({ username: user?.username ?? 'Unknown', role: user?.role ?? 'user', auth_provider: user?.auth_provider ?? 'local', invite_management_enabled: Boolean(user?.invite_management_enabled ?? false), }) setStats(data?.stats ?? null) setActivity(data?.activity ?? null) setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false)) setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false)) setMasterInviteTemplate(inviteData?.master_invite ?? null) } catch (err) { console.error(err) setStatus('Could not load your profile.') } finally { setLoading(false) } } void load() }, [router]) const submit = async (event: React.FormEvent) => { event.preventDefault() setStatus(null) if (!currentPassword || !newPassword) { setStatus('Enter your current password and a new password.') return } try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current_password: currentPassword, new_password: newPassword, }), }) if (!response.ok) { let detail = 'Update failed' try { const payload = await response.json() if (typeof payload?.detail === 'string' && payload.detail.trim()) { detail = payload.detail } } catch { const text = await response.text().catch(() => '') if (text?.trim()) detail = text } throw new Error(detail) } const data = await response.json().catch(() => ({})) setCurrentPassword('') setNewPassword('') setStatus( data?.provider === 'jellyfin' ? 'Password updated in Jellyfin (and Magent cache).' : 'Password updated.' ) } catch (err) { console.error(err) if (err instanceof Error && err.message) { setStatus(`Could not update password. ${err.message}`) } else { setStatus('Could not update password. Check your current password.') } } } const resetInviteEditor = () => { setInviteEditingId(null) setInviteForm(defaultOwnedInviteForm()) } const editInvite = (invite: OwnedInvite) => { setInviteEditingId(invite.id) setInviteError(null) setInviteStatus(null) setInviteForm({ code: invite.code ?? '', label: invite.label ?? '', description: invite.description ?? '', max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '', expires_at: invite.expires_at ?? '', enabled: invite.enabled !== false, }) } const reloadInvites = async () => { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/auth/profile/invites`) if (!response.ok) { if (response.status === 401) { clearToken() router.push('/login') return } throw new Error(`Invite refresh failed: ${response.status}`) } const data = (await response.json()) as OwnedInvitesResponse setInvites(Array.isArray(data?.invites) ? data.invites : []) setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false)) setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false)) setMasterInviteTemplate(data?.master_invite ?? null) } const saveInvite = async (event: React.FormEvent) => { event.preventDefault() setInviteSaving(true) setInviteError(null) setInviteStatus(null) try { const baseUrl = getApiBase() const response = await authFetch( inviteEditingId == null ? `${baseUrl}/auth/profile/invites` : `${baseUrl}/auth/profile/invites/${inviteEditingId}`, { method: inviteEditingId == null ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: inviteForm.code || null, label: inviteForm.label || null, description: inviteForm.description || null, max_uses: inviteForm.max_uses || null, expires_at: inviteForm.expires_at || null, enabled: inviteForm.enabled, }), } ) if (!response.ok) { if (response.status === 401) { clearToken() router.push('/login') return } const text = await response.text() throw new Error(text || 'Invite save failed') } setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.') resetInviteEditor() await reloadInvites() } catch (err) { console.error(err) setInviteError(err instanceof Error ? err.message : 'Could not save invite.') } finally { setInviteSaving(false) } } const deleteInvite = async (invite: OwnedInvite) => { if (!window.confirm(`Delete invite "${invite.code}"?`)) return setInviteError(null) setInviteStatus(null) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, { method: 'DELETE', }) if (!response.ok) { if (response.status === 401) { clearToken() router.push('/login') return } const text = await response.text() throw new Error(text || 'Invite delete failed') } if (inviteEditingId === invite.id) { resetInviteEditor() } setInviteStatus(`Deleted invite ${invite.code}.`) await reloadInvites() } catch (err) { console.error(err) setInviteError(err instanceof Error ? err.message : 'Could not delete invite.') } } const copyInviteLink = async (invite: OwnedInvite) => { const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}` try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(url) setInviteStatus(`Copied invite link for ${invite.code}.`) } else { window.prompt('Copy invite link', url) } } catch (err) { console.error(err) window.prompt('Copy invite link', url) } } const authProvider = profile?.auth_provider ?? 'local' const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin' const securityHelpText = authProvider === 'jellyfin' ? 'Changing your password here updates your Jellyfin account and refreshes Magent’s cached sign-in.' : authProvider === 'local' ? 'Change your Magent account password.' : 'Password changes are not available for this sign-in provider.' useEffect(() => { if (activeTab === 'invites' && !canManageInvites) { setActiveTab('overview') } }, [activeTab, canManageInvites]) if (loading) { return
Loading profile...
} return (

My profile

{profile && (
Signed in as {profile.username} ({profile.role}). Login type:{' '} {profile.auth_provider}.
)}
{canManageInvites ? ( ) : null}
{activeTab === 'overview' && (

Account stats

Requests submitted
{stats?.total ?? 0}
Ready to watch
{stats?.ready ?? 0}
In progress
{stats?.in_progress ?? 0}
Pending approval
{stats?.pending ?? 0}
Declined
{stats?.declined ?? 0}
Working
{stats?.working ?? 0}
Partial
{stats?.partial ?? 0}
Approved
{stats?.approved ?? 0}
Last request
{formatDate(stats?.last_request_at)}
Share of all requests
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
Total requests (global)
{stats?.global_total ?? 0}
{profile?.role === 'admin' ? (
Most active user
{stats?.most_active_user ? `${stats.most_active_user.username} (${stats.most_active_user.total})` : 'N/A'}
) : null}
)} {activeTab === 'activity' && (

Connection history

Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
{(activity?.recent ?? []).map((entry, index) => (
{parseBrowser(entry.user_agent)}
IP: {entry.ip}
First seen: {formatDate(entry.first_seen_at)}
Last seen: {formatDate(entry.last_seen_at)}
{entry.hit_count} visits
))} {activity && activity.recent.length === 0 ? (
No connection history yet.
) : null}
)} {activeTab === 'invites' && (

My invites

{inviteManagedByMaster ? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.' : 'Create and manage invite links you’ve issued. New invites use your account defaults.'}

{inviteError &&
{inviteError}
} {inviteStatus &&
{inviteStatus}
}

{inviteEditingId == null ? 'Create invite' : 'Edit invite'}

Share the generated signup link with the person you want to invite.

{inviteManagedByMaster && masterInviteTemplate ? (
Using master invite rule {masterInviteTemplate.code} {masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
) : null}
Identity Optional code and label for easier tracking.
Description Optional note shown on the signup page.