feat: add Apprise sidecar user/admin notifications
This commit is contained in:
@@ -27,6 +27,7 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
radarr: 'Radarr',
|
||||
prowlarr: 'Prowlarr',
|
||||
qbittorrent: 'qBittorrent',
|
||||
apprise: 'Apprise',
|
||||
log: 'Activity log',
|
||||
requests: 'Request sync',
|
||||
site: 'Site',
|
||||
@@ -42,6 +43,7 @@ const URL_SETTINGS = new Set([
|
||||
'radarr_base_url',
|
||||
'prowlarr_base_url',
|
||||
'qbittorrent_base_url',
|
||||
'apprise_base_url',
|
||||
])
|
||||
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
||||
|
||||
@@ -54,6 +56,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
radarr: 'Movie automation settings.',
|
||||
prowlarr: 'Indexer search settings.',
|
||||
qbittorrent: 'Downloader connection settings.',
|
||||
apprise: 'Configure the external Apprise sidecar used for notifications.',
|
||||
requests: 'Control how often requests are refreshed and cleaned up.',
|
||||
log: 'Activity log for troubleshooting.',
|
||||
site: 'Sitewide banner, version, and changelog details.',
|
||||
@@ -67,6 +70,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
radarr: 'radarr',
|
||||
prowlarr: 'prowlarr',
|
||||
qbittorrent: 'qbittorrent',
|
||||
apprise: 'apprise',
|
||||
requests: 'requests',
|
||||
cache: null,
|
||||
logs: 'log',
|
||||
@@ -366,6 +370,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
|
||||
qbittorrent_username: 'qBittorrent login username.',
|
||||
qbittorrent_password: 'qBittorrent login password.',
|
||||
apprise_base_url:
|
||||
'External Apprise API base URL for notifications (for example http://apprise:8000).',
|
||||
apprise_api_key:
|
||||
'Optional API key Magent uses when calling your external Apprise service.',
|
||||
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
|
||||
requests_poll_interval_seconds:
|
||||
'How often Magent checks if a full refresh should run.',
|
||||
@@ -393,6 +401,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
|
||||
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
|
||||
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
|
||||
apprise_base_url: 'http://apprise:8000 or https://notify.example.com',
|
||||
}
|
||||
|
||||
const buildSelectOptions = (
|
||||
|
||||
281
frontend/app/admin/notifications/page.tsx
Normal file
281
frontend/app/admin/notifications/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -72,6 +72,10 @@ export default function ProfilePage() {
|
||||
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(() => {
|
||||
@@ -97,6 +101,14 @@ export default function ProfilePage() {
|
||||
})
|
||||
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.')
|
||||
@@ -137,6 +149,59 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -222,6 +287,42 @@ export default function ProfilePage() {
|
||||
</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.
|
||||
|
||||
@@ -22,6 +22,13 @@ const NAV_GROUPS = [
|
||||
{ href: '/admin/cache', label: 'Cache Control' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
items: [
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
{ href: '/admin/apprise', label: 'Apprise' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
items: [
|
||||
|
||||
Reference in New Issue
Block a user