282 lines
8.3 KiB
TypeScript
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>
|
|
)
|
|
}
|