Files
Magent/frontend/app/profile/page.tsx

359 lines
12 KiB
TypeScript

'use client'
import { useEffect, 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
}
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
}
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<ProfileInfo | null>(null)
const [stats, setStats] = useState<ProfileStats | null>(null)
const [activity, setActivity] = useState<ProfileActivity | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [notifyEnabled, setNotifyEnabled] = useState(false)
const [notifyUrls, setNotifyUrls] = useState('')
const [notifyStatus, setNotifyStatus] = useState<string | null>(null)
const [notifySaving, setNotifySaving] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
const data = await response.json()
const user = data?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
})
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
const notifyResponse = await authFetch(`${baseUrl}/auth/notifications`)
if (notifyResponse.ok) {
const notifyData = await notifyResponse.json()
setNotifyEnabled(Boolean(notifyData?.enabled))
const urls = Array.isArray(notifyData?.urls) ? notifyData.urls : []
setNotifyUrls(urls.join('\n'))
}
} 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) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setCurrentPassword('')
setNewPassword('')
setStatus('Password updated.')
} catch (err) {
console.error(err)
setStatus('Could not update password. Check your current password.')
}
}
const saveNotifications = async (event: React.FormEvent) => {
event.preventDefault()
setNotifyStatus(null)
setNotifySaving(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/notifications`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: notifyEnabled,
urls: notifyUrls,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setNotifyStatus('Notification settings saved.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not save notification settings.'
setNotifyStatus(message)
} finally {
setNotifySaving(false)
}
}
const sendTest = async () => {
setNotifyStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/notifications/test`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Test failed')
}
setNotifyStatus('Test notification sent.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not send test notification.'
setNotifyStatus(message)
}
}
if (loading) {
return <main className="card">Loading profile...</main>
}
return (
<main className="card">
<h1>My profile</h1>
{profile && (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
{profile.auth_provider}.
</div>
)}
<div className="profile-grid">
<section className="profile-section">
<h2>Account stats</h2>
<div className="stat-grid">
<div className="stat-card">
<div className="stat-label">Requests submitted</div>
<div className="stat-value">{stats?.total ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Ready to watch</div>
<div className="stat-value">{stats?.ready ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">In progress</div>
<div className="stat-value">{stats?.in_progress ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Pending approval</div>
<div className="stat-value">{stats?.pending ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Declined</div>
<div className="stat-value">{stats?.declined ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Last request</div>
<div className="stat-value stat-value--small">
{formatDate(stats?.last_request_at)}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Share of all requests</div>
<div className="stat-value">
{stats?.global_total
? `${Math.round((stats.share || 0) * 1000) / 10}%`
: '0%'}
</div>
</div>
{profile?.role === 'admin' ? (
<div className="stat-card">
<div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small">
{stats?.most_active_user
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
: 'N/A'}
</div>
</div>
) : null}
</div>
</section>
<section className="profile-section">
<h2>Connection history</h2>
<div className="status-banner">
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
</div>
<div className="connection-list">
{(activity?.recent ?? []).map((entry, index) => (
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
<div>
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
<div className="meta">IP: {entry.ip}</div>
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
</div>
<div className="connection-count">{entry.hit_count} visits</div>
</div>
))}
{activity && activity.recent.length === 0 ? (
<div className="status-banner">No connection history yet.</div>
) : null}
</div>
</section>
</div>
<section className="profile-section">
<h2>Notifications</h2>
<div className="status-banner">
Add Apprise URLs to receive notifications (one URL per line).
</div>
<form onSubmit={saveNotifications} className="auth-form">
<label>
Enable notifications
<select
value={notifyEnabled ? 'true' : 'false'}
onChange={(event) => setNotifyEnabled(event.target.value === 'true')}
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</label>
<label>
Apprise URLs
<textarea
rows={4}
placeholder="discord://token@webhook_id\nmailto://user:pass@server"
value={notifyUrls}
onChange={(event) => setNotifyUrls(event.target.value)}
/>
</label>
{notifyStatus && <div className="status-banner">{notifyStatus}</div>}
<div className="auth-actions">
<button type="submit" disabled={notifySaving}>
{notifySaving ? 'Saving...' : 'Save notifications'}
</button>
<button type="button" className="ghost-button" onClick={sendTest}>
Send test
</button>
</div>
</form>
</section>
{profile?.auth_provider !== 'local' ? (
<div className="status-banner">
Password changes are only available for local Magent accounts.
</div>
) : (
<form onSubmit={submit} className="auth-form">
<label>
Current password
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit">Update password</button>
</div>
</form>
)}
</main>
)
}