Add user stats and activity tracking
This commit is contained in:
@@ -559,6 +559,73 @@ button span {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-value--small {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.connection-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.connection-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-count {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -685,12 +752,28 @@ button span {
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-card strong {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-meta .meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1551,6 +1634,15 @@ button span {
|
||||
.cache-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.connection-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -10,9 +10,65 @@ type ProfileInfo = {
|
||||
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)
|
||||
@@ -26,18 +82,21 @@ export default function ProfilePage() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||
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: data?.username ?? 'Unknown',
|
||||
role: data?.role ?? 'user',
|
||||
auth_provider: data?.auth_provider ?? 'local',
|
||||
username: user?.username ?? 'Unknown',
|
||||
role: user?.role ?? 'user',
|
||||
auth_provider: user?.auth_provider ?? 'local',
|
||||
})
|
||||
setStats(data?.stats ?? null)
|
||||
setActivity(data?.activity ?? null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('Could not load your profile.')
|
||||
@@ -91,6 +150,76 @@ export default function ProfilePage() {
|
||||
{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>
|
||||
<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>
|
||||
</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>
|
||||
{profile?.auth_provider !== 'local' ? (
|
||||
<div className="status-banner">
|
||||
Password changes are only available for local Magent accounts.
|
||||
|
||||
@@ -136,9 +136,11 @@ export default function UsersPage() {
|
||||
<div key={user.username} className="summary-card user-card">
|
||||
<div>
|
||||
<strong>{user.username}</strong>
|
||||
<span className="meta">Role: {user.role}</span>
|
||||
<span className="meta">Login type: {user.authProvider || 'local'}</span>
|
||||
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
|
||||
<div className="user-meta">
|
||||
<span className="meta">Role: {user.role}</span>
|
||||
<span className="meta">Login type: {user.authProvider || 'local'}</span>
|
||||
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-actions">
|
||||
<label className="toggle">
|
||||
|
||||
Reference in New Issue
Block a user