Files
Magent/frontend/app/admin/notifications/page.tsx

282 lines
8.3 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type NotificationUser = {
username: string
role?: string | null
authProvider?: string | null
jellyseerrUserId?: number | null
isBlocked?: boolean
notifyEnabled?: boolean
notifyCount?: number
}
type SendResult = {
username: string
status: string
}
const formatStatus = (user: NotificationUser) => {
if (user.isBlocked) return 'Blocked'
if (!user.notifyEnabled) return 'Disabled'
if (user.notifyCount && user.notifyCount > 0) return `Enabled (${user.notifyCount})`
return 'No targets'
}
export default function AdminNotificationsPage() {
const router = useRouter()
const [users, setUsers] = useState<NotificationUser[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [sending, setSending] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const [sendResults, setSendResults] = useState<SendResult[]>([])
const selectedCount = selected.size
const selectableUsers = useMemo(
() => users.filter((user) => user.username && !user.isBlocked),
[users]
)
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
setLoading(true)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/notifications/users`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Load failed')
}
const data = await response.json()
const fetched = Array.isArray(data?.users) ? data.users : []
setUsers(fetched)
setSelected(new Set())
} catch (err) {
console.error(err)
setStatus('Unable to load notification targets.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const toggleUser = (username: string) => {
setSelected((current) => {
const next = new Set(current)
if (next.has(username)) {
next.delete(username)
} else {
next.add(username)
}
return next
})
}
const selectAll = () => {
const next = new Set<string>()
for (const user of selectableUsers) {
if (user.username) {
next.add(user.username)
}
}
setSelected(next)
}
const selectEnabled = () => {
const next = new Set<string>()
for (const user of selectableUsers) {
if (user.username && user.notifyEnabled && (user.notifyCount ?? 0) > 0) {
next.add(user.username)
}
}
setSelected(next)
}
const clearSelection = () => {
setSelected(new Set())
}
const send = async () => {
setStatus(null)
setSendResults([])
if (selectedCount === 0) {
setStatus('Select at least one user.')
return
}
if (!message.trim()) {
setStatus('Message cannot be empty.')
return
}
setSending(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/notifications/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
usernames: Array.from(selected),
title: title.trim() || 'Magent admin message',
message: message.trim(),
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Send failed')
}
const data = await response.json()
const results = Array.isArray(data?.results) ? data.results : []
setSendResults(results)
setStatus(
`Sent ${data?.sent ?? 0}. Skipped ${data?.skipped ?? 0}. Failed ${data?.failed ?? 0}.`
)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Send failed.'
setStatus(message)
} finally {
setSending(false)
}
}
return (
<AdminShell
title="User notifications"
subtitle="Send admin messages to users via their Apprise targets."
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section">
<div className="admin-toolbar">
<div className="admin-toolbar-info">
<span>{users.length.toLocaleString()} users</span>
<span>{selectedCount.toLocaleString()} selected</span>
</div>
<div className="admin-toolbar-actions">
<button type="button" onClick={selectAll} disabled={loading}>
Select all
</button>
<button type="button" onClick={selectEnabled} disabled={loading}>
Select enabled
</button>
<button type="button" className="ghost-button" onClick={clearSelection}>
Clear
</button>
</div>
</div>
{loading ? (
<div className="status-banner">Loading notification targets</div>
) : users.length === 0 ? (
<div className="status-banner">No users found.</div>
) : (
<div className="admin-table">
<div className="admin-table-head">
<span>Select</span>
<span>User</span>
<span>Role</span>
<span>Status</span>
</div>
{users.map((user) => {
const username = user.username || 'Unknown'
const isChecked = selected.has(username)
return (
<div key={username} className="admin-table-row">
<span>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleUser(username)}
disabled={!username || user.isBlocked}
/>
</span>
<span>{username}</span>
<span>{user.role || 'user'}</span>
<span>{formatStatus(user)}</span>
</div>
)
})}
</div>
)}
</section>
<section className="admin-section">
<div className="section-header">
<h2>Message</h2>
</div>
<div className="admin-form">
<label>
<span className="label-row">
<span>Title</span>
<span className="meta">Optional</span>
</span>
<input
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Magent admin message"
/>
</label>
<label>
<span className="label-row">
<span>Message</span>
<span className="meta">Required</span>
</span>
<textarea
rows={4}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Write the message you want to send."
/>
</label>
</div>
{status && <div className="status-banner">{status}</div>}
<div className="admin-actions">
<button type="button" onClick={send} disabled={sending}>
{sending ? 'Sending…' : 'Send message'}
</button>
</div>
{sendResults.length > 0 && (
<div className="admin-table">
<div className="admin-table-head">
<span>User</span>
<span>Result</span>
</div>
{sendResults.map((result) => (
<div key={`${result.username}-${result.status}`} className="admin-table-row">
<span>{result.username}</span>
<span>{result.status}</span>
</div>
))}
</div>
)}
</section>
</AdminShell>
)
}