Merge pull request #89 from christianlouis/copilot/add-apprise-alerting-capabilities

Add Apprise-powered alerting for users and admins
This commit is contained in:
Christian Krakau-Louis
2026-03-26 19:41:27 +01:00
committed by GitHub
13 changed files with 1781 additions and 9 deletions
+14
View File
@@ -37,6 +37,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Dashboard** — "Recent Processing Runs" table now reads from the new `/processing-runs` endpoint; shows account name and a *View all logs* link. - **Dashboard** — "Recent Processing Runs" table now reads from the new `/processing-runs` endpoint; shows account name and a *View all logs* link.
### Added ### Added
- **Apprise alerting**: New `NotificationService` using [Apprise](https://github.com/caronc/apprise) for multi-channel push notifications (Telegram, Slack, Discord, webhooks, and 80+ other services via a single URL scheme).
- `send_user_notification` — sends to all enabled per-user Apprise channels on processing errors or failures.
- `send_admin_notification` — sends to all enabled admin-wide channels for system events.
- `test_notification` — validates an Apprise URL by dispatching a test message.
- **`NotificationConfig` model**: Added `name` (friendly label) and `apprise_url` (nullable Apprise URL) columns.
- **`AdminNotificationConfig` model**: New table (`admin_notification_configs`) for system-wide admin alert channels with `name`, `apprise_url`, `is_enabled`, `notify_on_errors`, `notify_on_system_events`, and `description` fields.
- **Notifications API** (`/api/v1/notifications`): Full CRUD endpoints (GET/POST/PUT/DELETE) plus a `/test` endpoint for user notification configs.
- **Admin Notifications API** (`/api/v1/admin/notifications`): Full CRUD + `/test` endpoints for admin notification configs, superuser-only.
- **Task integration**: `process_mail_account` now calls `send_user_notification` on Gmail credential revocation, per-email forwarding failures, and unhandled processing exceptions.
### Changed
- `NotificationConfigBase` schema: `name` is now a required field; `apprise_url` is an optional field; `config` (channel-specific JSON) is now optional with a default of `{}` (previously required). Existing clients must be updated to supply `name`.
- **Configurable Gmail import labels**: Users can now define which Gmail labels are applied to imported messages from the Settings page. The default setup is opinionated: `{{source_email}}` (rendered to the mailbox address each message came from) plus `imported`, and a reset button restores those defaults instantly. - **Configurable Gmail import labels**: Users can now define which Gmail labels are applied to imported messages from the Settings page. The default setup is opinionated: `{{source_email}}` (rendered to the mailbox address each message came from) plus `imported`, and a reset button restores those defaults instantly.
- **Prometheus metrics** (`/metrics` endpoint on the FastAPI backend, scraped every 15 s): - **Prometheus metrics** (`/metrics` endpoint on the FastAPI backend, scraped every 15 s):
- **HTTP layer** — `http_requests_total` (counter, labelled `method`/`endpoint`/`status_code`) and `http_request_duration_seconds` (histogram). Path segments that are numeric IDs are normalised to `{id}` to avoid label-set explosion. - **HTTP layer** — `http_requests_total` (counter, labelled `method`/`endpoint`/`status_code`) and `http_request_duration_seconds` (histogram). Path segments that are numeric IDs are normalised to `{id}` to avoid label-set explosion.
+116
View File
@@ -16,6 +16,7 @@ from app.models.database_models import (
ProcessingRun, ProcessingRun,
SubscriptionPlan, SubscriptionPlan,
SubscriptionTier, SubscriptionTier,
AdminNotificationConfig,
) )
from app.models.schemas import ( from app.models.schemas import (
AdminUserListResponse, AdminUserListResponse,
@@ -24,11 +25,17 @@ from app.models.schemas import (
SubscriptionPlanResponse, SubscriptionPlanResponse,
SubscriptionPlanCreate, SubscriptionPlanCreate,
SubscriptionPlanUpdate, SubscriptionPlanUpdate,
AdminNotificationConfigCreate,
AdminNotificationConfigUpdate,
AdminNotificationConfigResponse,
NotificationTestRequest,
NotificationTestResponse,
AdminProcessingRunResponse, AdminProcessingRunResponse,
AdminProcessingLogResponse, AdminProcessingLogResponse,
PaginatedAdminRunsResponse, PaginatedAdminRunsResponse,
PaginatedAdminLogsResponse, PaginatedAdminLogsResponse,
) )
from app.services.notification_service import test_notification
router = APIRouter() router = APIRouter()
@@ -291,6 +298,115 @@ async def delete_plan(
await db.commit() await db.commit()
# ── Admin notification config management ──────────────────────────────────────
@router.get("/notifications", response_model=List[AdminNotificationConfigResponse])
async def list_admin_notification_configs(
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""List all admin notification configurations (admin only)"""
result = await db.execute(select(AdminNotificationConfig))
return result.scalars().all()
@router.post(
"/notifications",
response_model=AdminNotificationConfigResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_admin_notification_config(
config_in: AdminNotificationConfigCreate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Create a new admin notification configuration (admin only)"""
config = AdminNotificationConfig(**config_in.dict())
db.add(config)
await db.commit()
await db.refresh(config)
return config
@router.get(
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
)
async def get_admin_notification_config(
config_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Get a specific admin notification configuration (admin only)"""
result = await db.execute(
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Admin notification config not found",
)
return config
@router.put(
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
)
async def update_admin_notification_config(
config_id: int,
config_in: AdminNotificationConfigUpdate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Update an admin notification configuration (admin only)"""
result = await db.execute(
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Admin notification config not found",
)
update_data = config_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
await db.commit()
await db.refresh(config)
return config
@router.delete("/notifications/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_admin_notification_config(
config_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Delete an admin notification configuration (admin only)"""
result = await db.execute(
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Admin notification config not found",
)
await db.delete(config)
await db.commit()
@router.post("/notifications/test", response_model=NotificationTestResponse)
async def test_admin_notification_config(
request: NotificationTestRequest,
current_user: User = Depends(get_current_superuser),
):
"""Test an admin notification channel by sending a test message (admin only)"""
success, message = await test_notification(request.apprise_url)
return NotificationTestResponse(success=success, message=message)
# ── Admin Logs ───────────────────────────────────────────────────────────────── # ── Admin Logs ─────────────────────────────────────────────────────────────────
+93 -3
View File
@@ -1,7 +1,7 @@
"""Notification configuration endpoints""" """Notification configuration endpoints"""
from typing import List from typing import List
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -10,8 +10,12 @@ from app.core.deps import get_current_active_user
from app.models.database_models import User, NotificationConfig from app.models.database_models import User, NotificationConfig
from app.models.schemas import ( from app.models.schemas import (
NotificationConfigCreate, NotificationConfigCreate,
NotificationConfigUpdate,
NotificationConfigResponse, NotificationConfigResponse,
NotificationTestRequest,
NotificationTestResponse,
) )
from app.services.notification_service import test_notification
router = APIRouter() router = APIRouter()
@@ -24,7 +28,7 @@ async def create_notification_config(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Create notification configuration""" """Create a new notification configuration"""
config = NotificationConfig(user_id=current_user.id, **config_in.dict()) config = NotificationConfig(user_id=current_user.id, **config_in.dict())
db.add(config) db.add(config)
await db.commit() await db.commit()
@@ -37,8 +41,94 @@ async def list_notification_configs(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""List all notification configurations""" """List all notification configurations for the current user"""
result = await db.execute( result = await db.execute(
select(NotificationConfig).where(NotificationConfig.user_id == current_user.id) select(NotificationConfig).where(NotificationConfig.user_id == current_user.id)
) )
return result.scalars().all() return result.scalars().all()
@router.get("/{config_id}", response_model=NotificationConfigResponse)
async def get_notification_config(
config_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get a specific notification configuration"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.id == config_id,
NotificationConfig.user_id == current_user.id,
)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification config not found",
)
return config
@router.put("/{config_id}", response_model=NotificationConfigResponse)
async def update_notification_config(
config_id: int,
config_in: NotificationConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Update a notification configuration"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.id == config_id,
NotificationConfig.user_id == current_user.id,
)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification config not found",
)
update_data = config_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
await db.commit()
await db.refresh(config)
return config
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_notification_config(
config_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a notification configuration"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.id == config_id,
NotificationConfig.user_id == current_user.id,
)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification config not found",
)
await db.delete(config)
await db.commit()
@router.post("/test", response_model=NotificationTestResponse)
async def test_notification_config(
request: NotificationTestRequest,
current_user: User = Depends(get_current_active_user),
):
"""Test a notification channel by sending a test message"""
success, message = await test_notification(request.apprise_url)
return NotificationTestResponse(success=success, message=message)
+31
View File
@@ -295,6 +295,11 @@ class NotificationConfig(Base):
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
) )
name = Column(String(255), nullable=False, default="My Notification")
apprise_url = Column(
Text, nullable=True
) # The Apprise URL e.g. tgram://token/chatid
# Channel details # Channel details
channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False) channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False)
is_enabled = Column(Boolean, default=True) is_enabled = Column(Boolean, default=True)
@@ -608,3 +613,29 @@ class AppSetting(Base):
onupdate=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),
nullable=False, nullable=False,
) )
class AdminNotificationConfig(Base):
"""System-wide admin notification channels"""
__tablename__ = "admin_notification_configs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
apprise_url = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True)
notify_on_errors = Column(Boolean, default=True)
notify_on_system_events = Column(Boolean, default=True)
description = Column(Text, nullable=True)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
+51 -1
View File
@@ -289,9 +289,16 @@ class PaginatedAdminLogsResponse(BaseModel):
# Notification Config Schemas # Notification Config Schemas
class NotificationConfigBase(BaseModel): class NotificationConfigBase(BaseModel):
name: str = Field(
..., max_length=255, description="Friendly name for this notification channel"
)
channel: NotificationChannel channel: NotificationChannel
apprise_url: Optional[str] = Field(None, description="Apprise notification URL")
is_enabled: bool = True is_enabled: bool = True
config: Dict[str, Any] config: Dict[str, Any] = Field(
default_factory=dict,
description="Legacy channel-specific configuration (deprecated in favour of apprise_url)",
)
notify_on_errors: bool = True notify_on_errors: bool = True
notify_on_success: bool = False notify_on_success: bool = False
notify_threshold: int = Field(default=3, gt=0, le=100) notify_threshold: int = Field(default=3, gt=0, le=100)
@@ -302,6 +309,9 @@ class NotificationConfigCreate(NotificationConfigBase):
class NotificationConfigUpdate(BaseModel): class NotificationConfigUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=255)
channel: Optional[NotificationChannel] = None
apprise_url: Optional[str] = None
is_enabled: Optional[bool] = None is_enabled: Optional[bool] = None
config: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None
notify_on_errors: Optional[bool] = None notify_on_errors: Optional[bool] = None
@@ -318,6 +328,46 @@ class NotificationConfigResponse(NotificationConfigBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class NotificationTestRequest(BaseModel):
apprise_url: str = Field(..., description="Apprise URL to test")
class NotificationTestResponse(BaseModel):
success: bool
message: str
# Admin Notification Config Schemas
class AdminNotificationConfigBase(BaseModel):
name: str = Field(..., max_length=255)
apprise_url: str = Field(..., description="Apprise notification URL")
is_enabled: bool = True
notify_on_errors: bool = True
notify_on_system_events: bool = True
description: Optional[str] = None
class AdminNotificationConfigCreate(AdminNotificationConfigBase):
pass
class AdminNotificationConfigUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=255)
apprise_url: Optional[str] = None
is_enabled: Optional[bool] = None
notify_on_errors: Optional[bool] = None
notify_on_system_events: Optional[bool] = None
description: Optional[str] = None
class AdminNotificationConfigResponse(AdminNotificationConfigBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Subscription Schemas # Subscription Schemas
class SubscriptionPlanResponse(BaseModel): class SubscriptionPlanResponse(BaseModel):
id: int id: int
@@ -0,0 +1,143 @@
"""
Notification service using Apprise for multi-channel alerting.
Supports:
- User-specific notifications (per-user Apprise URLs)
- Admin-wide system notifications (system-level alerts)
- Test notifications to verify configuration
"""
import logging
import apprise
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.database_models import NotificationConfig, AdminNotificationConfig
logger = logging.getLogger(__name__)
async def send_user_notification(
db: AsyncSession,
user_id: int,
title: str,
body: str,
notify_on_error: bool = True,
) -> int:
"""
Send a notification to all enabled notification channels for a given user.
Args:
db: Database session
user_id: The user to notify
title: Notification title/subject
body: Notification body text
notify_on_error: If True, only sends to channels with notify_on_errors=True
If False, only sends to channels with notify_on_success=True
Returns:
Number of channels notified successfully
"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.user_id == user_id,
NotificationConfig.is_enabled == True, # noqa: E712
NotificationConfig.apprise_url.isnot(None),
)
)
configs = result.scalars().all()
if not configs:
return 0
sent = 0
for config in configs:
if notify_on_error and not config.notify_on_errors:
continue
if not notify_on_error and not config.notify_on_success:
continue
try:
success = await _send_apprise(config.apprise_url or "", title, body)
if success:
sent += 1
except Exception as exc:
logger.warning(
"Failed to send notification via channel %s (user %s): %s",
config.id,
user_id,
exc,
)
return sent
async def send_admin_notification(
db: AsyncSession,
title: str,
body: str,
) -> int:
"""
Send a notification to all enabled admin notification channels.
Returns:
Number of channels notified successfully
"""
result = await db.execute(
select(AdminNotificationConfig).where(
AdminNotificationConfig.is_enabled == True, # noqa: E712
AdminNotificationConfig.notify_on_errors == True, # noqa: E712
)
)
configs = result.scalars().all()
if not configs:
return 0
sent = 0
for config in configs:
try:
success = await _send_apprise(config.apprise_url or "", title, body)
if success:
sent += 1
except Exception as exc:
logger.warning(
"Failed to send admin notification via channel %s: %s",
config.id,
exc,
)
return sent
async def test_notification(apprise_url: str) -> tuple[bool, str]:
"""
Send a test notification to the given Apprise URL.
Returns:
(success, message) tuple
"""
try:
success = await _send_apprise(
apprise_url,
title="InboxRescue: Test Notification",
body="This is a test notification from InboxRescue. Your notification channel is configured correctly!",
)
if success:
return True, "Test notification sent successfully"
return False, "Notification delivery failed (check your Apprise URL)"
except Exception as exc:
return False, f"Error sending test notification: {exc}"
async def _send_apprise(url: str, title: str, body: str) -> bool:
"""Internal helper create an Apprise instance, load the URL, and notify."""
ap = apprise.Apprise()
if not ap.add(url):
logger.warning("Apprise could not parse URL: %s", url[:60])
return False
result = await ap.async_notify(title=title, body=body)
return bool(result)
+37
View File
@@ -34,6 +34,7 @@ from app.models.database_models import (
from app.services.mail_processor import MailProcessor from app.services.mail_processor import MailProcessor
from app.services.gmail_service import GmailService from app.services.gmail_service import GmailService
from app.services.config_service import ConfigService from app.services.config_service import ConfigService
from app.services.notification_service import send_user_notification
from app.core.config import settings from app.core.config import settings
from sqlalchemy import select, delete from sqlalchemy import select, delete
@@ -272,6 +273,18 @@ async def process_mail_account(account_id: int):
f"Gmail credentials revoked for user {account.user_id}. " f"Gmail credentials revoked for user {account.user_id}. "
"User must re-authorise." "User must re-authorise."
) )
try:
await send_user_notification(
db=db,
user_id=account.user_id,
title="InboxRescue: Gmail Authorization Expired",
body=f"Your Gmail credentials for account '{account.name}' have been revoked. Please re-authorize Gmail access in Settings.",
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(
f"Failed to send revocation notification: {notify_exc}"
)
logger.error(f"Error delivering email: {e}") logger.error(f"Error delivering email: {e}")
error_msg = str(e) error_msg = str(e)
emails_failed += 1 emails_failed += 1
@@ -340,6 +353,16 @@ async def process_mail_account(account_id: int):
account.status = AccountStatus.ERROR # type: ignore[assignment] account.status = AccountStatus.ERROR # type: ignore[assignment]
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment] account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment] account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment]
try:
await send_user_notification(
db=db,
user_id=account.user_id,
title="InboxRescue: Mail Forwarding Failures",
body=f"Mail account '{account.name}': {emails_failed} email(s) failed to forward.",
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(f"Failed to send notification: {notify_exc}")
await db.commit() await db.commit()
@@ -392,6 +415,20 @@ async def process_mail_account(account_id: int):
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment] account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
account.last_error_message = str(e) # type: ignore[assignment] account.last_error_message = str(e) # type: ignore[assignment]
# Notify user about the error
try:
await send_user_notification(
db=db,
user_id=account.user_id,
title="InboxRescue: Mail Processing Error",
body=f"Error processing mail account '{account.name}': {e}",
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(
f"Failed to send error notification: {notify_exc}"
)
await db.commit() await db.commit()
+4 -1
View File
@@ -205,7 +205,7 @@ Comprehensive task breakdown for repository improvements and production readines
- [x] **Debug email**: "Send Debug Email" button in Settings injects a test message (from christian@docuelevate.org, dated today, labelled `test` + `imported`, placed in inbox) to verify end-to-end Gmail API delivery - [x] **Debug email**: "Send Debug Email" button in Settings injects a test message (from christian@docuelevate.org, dated today, labelled `test` + `imported`, placed in inbox) to verify end-to-end Gmail API delivery
- [x] **Logging & reporting**: per-email ProcessingLog capture in worker; user `/logs` page; admin `/admin/logs` page; GDPR masking utilities (`gdpr.py`) - [x] **Logging & reporting**: per-email ProcessingLog capture in worker; user `/logs` page; admin `/admin/logs` page; GDPR masking utilities (`gdpr.py`)
- [ ] Implement GDPR data export endpoint - [ ] Implement GDPR data export endpoint
- [ ] Complete notification service integration (Apprise) - [x] Complete notification service integration (Apprise)
- [ ] Add advanced email filtering - [ ] Add advanced email filtering
- [ ] Implement OAuth2 for Gmail (instead of App Passwords) - [ ] Implement OAuth2 for Gmail (instead of App Passwords)
- [ ] Add attachment handling improvements - [ ] Add attachment handling improvements
@@ -248,6 +248,9 @@ because the API client layer is missing.
- [ ] Error boundary components - [ ] Error boundary components
- [ ] Loading skeletons / proper loading states - [ ] Loading skeletons / proper loading states
- [ ] Notification preferences UI - [ ] Notification preferences UI
- [x] Notification channels page (`/notifications`) with full CRUD, wizard, and test button
- [x] Apprise-powered notification wizard for Telegram, Discord, Slack, Email, Webhook, and custom URLs
- [x] Admin system alert channels section (`/admin` page) with full CRUD and test
- [ ] Subscription management / billing UI - [ ] Subscription management / billing UI
### Admin Interface ✅ ### Admin Interface ✅
+422 -4
View File
@@ -2,17 +2,225 @@
import { AuthGuard } from '@/components/AuthGuard'; import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout'; import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '@/lib/api'; import {
adminApi,
adminNotificationsApi,
AdminNotificationConfig,
AdminNotificationConfigCreate,
AdminNotificationConfigUpdate,
} from '@/lib/api';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { Users, Mail, Activity, Shield } from 'lucide-react'; import {
Users,
Mail,
Activity,
Shield,
Bell,
Plus,
Edit2,
Trash2,
Send,
CheckCircle,
XCircle,
Loader2,
X,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
// ── Admin Notification Modal ─────────────────────────────────────────────
interface AdminNotificationModalProps {
config?: AdminNotificationConfig | null;
onClose: () => void;
}
function AdminNotificationModal({ config, onClose }: AdminNotificationModalProps) {
const queryClient = useQueryClient();
const isEdit = !!config;
const [formData, setFormData] = useState<AdminNotificationConfigCreate>({
name: config?.name ?? '',
apprise_url: config?.apprise_url ?? '',
is_enabled: config?.is_enabled ?? true,
notify_on_errors: config?.notify_on_errors ?? true,
notify_on_system_events: config?.notify_on_system_events ?? true,
description: config?.description ?? '',
});
const [error, setError] = useState('');
const onSuccess = () => {
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] });
onClose();
};
const createMutation = useMutation({
mutationFn: (data: AdminNotificationConfigCreate) => adminNotificationsApi.create(data),
onSuccess,
onError: () => setError('Failed to save. Please check the Apprise URL and try again.'),
});
const updateMutation = useMutation({
mutationFn: (data: AdminNotificationConfigUpdate) =>
adminNotificationsApi.update(config!.id, data),
onSuccess,
onError: () => setError('Failed to save. Please check the Apprise URL and try again.'),
});
const isPending = createMutation.isPending || updateMutation.isPending;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!formData.name.trim() || !formData.apprise_url.trim()) {
setError('Name and Apprise URL are required.');
return;
}
if (isEdit) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between p-5 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
{isEdit ? 'Edit System Alert Channel' : 'Add System Alert Channel'}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
placeholder="e.g. Admin Telegram Alert"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Apprise URL</label>
<input
type="text"
value={formData.apprise_url}
onChange={(e) => setFormData((p) => ({ ...p, apprise_url: e.target.value }))}
placeholder="tgram://bot_token/chat_id/"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<p className="text-xs text-gray-500 mt-1">
Any valid{' '}
<a
href="https://apprise.readthedocs.io"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Apprise
</a>{' '}
notification URL.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description{' '}
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
type="text"
value={formData.description ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value || null }))}
placeholder="What is this channel used for?"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Triggers</p>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_enabled}
onChange={(e) => setFormData((p) => ({ ...p, is_enabled: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<span className="text-sm text-gray-800">Enabled</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.notify_on_errors}
onChange={(e) => setFormData((p) => ({ ...p, notify_on_errors: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<span className="text-sm text-gray-800">Notify on errors</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.notify_on_system_events}
onChange={(e) =>
setFormData((p) => ({ ...p, notify_on_system_events: e.target.checked }))
}
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<span className="text-sm text-gray-800">Notify on system events</span>
</label>
</div>
{error && <p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded p-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="flex-1 py-2 bg-purple-600 text-white rounded-md text-sm hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{isEdit ? 'Save Changes' : 'Add Channel'}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Admin Page ───────────────────────────────────────────────────────────
export default function AdminPage() { export default function AdminPage() {
const { user } = useAuthStore(); const { user } = useAuthStore();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const [showNotifModal, setShowNotifModal] = useState(false);
const [editingNotif, setEditingNotif] = useState<AdminNotificationConfig | null>(null);
const [testingNotifId, setTestingNotifId] = useState<number | null>(null);
const [notifTestResults, setNotifTestResults] = useState<
Record<number, { success: boolean; message: string }>
>({});
useEffect(() => { useEffect(() => {
if (user && !user.is_superuser) { if (user && !user.is_superuser) {
@@ -26,6 +234,60 @@ export default function AdminPage() {
enabled: !!user?.is_superuser, enabled: !!user?.is_superuser,
}); });
const { data: adminNotifications, isLoading: notifLoading } = useQuery({
queryKey: ['admin-notifications'],
queryFn: adminNotificationsApi.list,
enabled: !!user?.is_superuser,
});
const deleteNotifMutation = useMutation({
mutationFn: adminNotificationsApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] });
},
});
const handleEditNotif = (config: AdminNotificationConfig) => {
setEditingNotif(config);
setShowNotifModal(true);
};
const handleDeleteNotif = async (id: number) => {
if (!confirm('Delete this system alert channel?')) return;
try {
await deleteNotifMutation.mutateAsync(id);
} catch {
alert('Failed to delete channel');
}
};
const handleTestNotif = async (config: AdminNotificationConfig) => {
setTestingNotifId(config.id);
try {
const result = await adminNotificationsApi.test(config.apprise_url);
setNotifTestResults((prev) => ({ ...prev, [config.id]: result }));
} catch {
setNotifTestResults((prev) => ({
...prev,
[config.id]: { success: false, message: 'Test request failed' },
}));
} finally {
setTestingNotifId(null);
setTimeout(() => {
setNotifTestResults((prev) => {
const next = { ...prev };
delete next[config.id];
return next;
});
}, 5000);
}
};
const handleCloseNotifModal = () => {
setShowNotifModal(false);
setEditingNotif(null);
};
// AuthGuard must always render so it can fetch the current user and handle // AuthGuard must always render so it can fetch the current user and handle
// unauthenticated redirects. The early-return that was here prevented // unauthenticated redirects. The early-return that was here prevented
// AuthGuard from ever mounting on a direct navigation to /admin, leaving a // AuthGuard from ever mounting on a direct navigation to /admin, leaving a
@@ -127,9 +389,165 @@ export default function AdminPage() {
</div> </div>
</Link> </Link>
</div> </div>
{/* System Alert Channels */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Bell className="h-5 w-5 text-purple-600" />
System Alert Channels
</h2>
<p className="text-sm text-gray-500 mt-0.5">
Admin-level channels that receive system-wide error and event notifications.
</p>
</div>
<button
onClick={() => {
setEditingNotif(null);
setShowNotifModal(true);
}}
className="flex items-center px-3 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4 mr-1.5" />
Add Channel
</button>
</div>
{notifLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
</div>
) : adminNotifications && adminNotifications.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{adminNotifications.map((config) => {
const testResult = notifTestResults[config.id];
const isTesting = testingNotifId === config.id;
return (
<div
key={config.id}
className={`bg-white rounded-lg shadow border transition-opacity ${
config.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
}`}
>
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">
{config.name}
</p>
{config.description && (
<p className="text-xs text-gray-500 mt-0.5 truncate">
{config.description}
</p>
)}
</div>
<span
className={`ml-2 flex-shrink-0 flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
config.is_enabled
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{config.is_enabled ? (
<CheckCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{config.is_enabled ? 'On' : 'Off'}
</span>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{config.notify_on_errors && (
<span className="px-1.5 py-0.5 rounded text-xs bg-red-50 text-red-700 border border-red-200">
On Errors
</span>
)}
{config.notify_on_system_events && (
<span className="px-1.5 py-0.5 rounded text-xs bg-purple-50 text-purple-700 border border-purple-200">
System Events
</span>
)}
</div>
{testResult && (
<div
className={`mb-3 p-2 rounded text-xs flex items-center gap-1.5 ${
testResult.success
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
{testResult.success ? (
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 flex-shrink-0" />
)}
{testResult.message}
</div>
)}
<div className="flex items-center gap-2 pt-3 border-t border-gray-100">
<button
onClick={() => handleTestNotif(config)}
disabled={isTesting}
title="Send test notification"
className="flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100 disabled:opacity-50 transition-colors"
>
{isTesting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5 mr-1" />
)}
{isTesting ? '…' : 'Test'}
</button>
<button
onClick={() => handleEditNotif(config)}
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
Edit
</button>
<button
onClick={() => handleDeleteNotif(config.id)}
disabled={deleteNotifMutation.isPending}
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 disabled:opacity-50 transition-colors"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-10 bg-white rounded-lg shadow border border-dashed border-gray-300">
<Bell className="mx-auto h-8 w-8 text-gray-300 mb-2" />
<p className="text-sm text-gray-500">No system alert channels configured yet.</p>
<button
onClick={() => {
setEditingNotif(null);
setShowNotifModal(true);
}}
className="mt-3 inline-flex items-center px-3 py-1.5 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4 mr-1.5" />
Add First Channel
</button>
</div>
)}
</div>
</div> </div>
)} )}
{showNotifModal && (
<AdminNotificationModal config={editingNotif} onClose={handleCloseNotifModal} />
)}
</DashboardLayout> </DashboardLayout>
</AuthGuard> </AuthGuard>
); );
} }
+347
View File
@@ -0,0 +1,347 @@
'use client';
import { useState } from 'react';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notificationsApi, NotificationConfig, NotificationConfigCreate, NotificationConfigUpdate } from '@/lib/api';
import { NotificationWizard } from '@/components/NotificationWizard';
import { Plus, Edit2, Trash2, Bell, Send, CheckCircle, XCircle, Loader2 } from 'lucide-react';
const CHANNEL_DISPLAY: Record<string, { icon: string; label: string; color: string }> = {
telegram: { icon: '🤖', label: 'Telegram', color: 'bg-blue-100 text-blue-800' },
discord: { icon: '💬', label: 'Discord', color: 'bg-indigo-100 text-indigo-800' },
slack: { icon: '💼', label: 'Slack', color: 'bg-yellow-100 text-yellow-800' },
email: { icon: '📧', label: 'Email', color: 'bg-green-100 text-green-800' },
webhook: { icon: '🔗', label: 'Webhook', color: 'bg-purple-100 text-purple-800' },
custom: { icon: '⚙️', label: 'Custom', color: 'bg-gray-100 text-gray-800' },
};
export default function NotificationsPage() {
const [showWizard, setShowWizard] = useState(false);
const [editingConfig, setEditingConfig] = useState<NotificationConfig | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [testResults, setTestResults] = useState<Record<number, { success: boolean; message: string }>>({});
const queryClient = useQueryClient();
const { data: notifications, isLoading } = useQuery({
queryKey: ['notifications'],
queryFn: notificationsApi.list,
});
const createMutation = useMutation({
mutationFn: (data: NotificationConfigCreate) => notificationsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
setShowWizard(false);
setEditingConfig(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: NotificationConfigUpdate }) =>
notificationsApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
setShowWizard(false);
setEditingConfig(null);
},
});
const deleteMutation = useMutation({
mutationFn: notificationsApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const toggleMutation = useMutation({
mutationFn: ({ id, is_enabled }: { id: number; is_enabled: boolean }) =>
notificationsApi.update(id, { is_enabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const handleWizardComplete = (config: {
name: string;
channel: string;
apprise_url: string;
notify_on_errors: boolean;
notify_on_success: boolean;
}) => {
if (editingConfig) {
updateMutation.mutate({ id: editingConfig.id, data: config });
} else {
createMutation.mutate(config);
}
};
const handleEdit = (config: NotificationConfig) => {
setEditingConfig(config);
setShowWizard(true);
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this notification channel?')) return;
try {
await deleteMutation.mutateAsync(id);
} catch {
alert('Failed to delete notification channel');
}
};
const handleTest = async (config: NotificationConfig) => {
if (!config.apprise_url) return;
setTestingId(config.id);
try {
const result = await notificationsApi.test(config.apprise_url);
setTestResults((prev) => ({ ...prev, [config.id]: result }));
} catch {
setTestResults((prev) => ({
...prev,
[config.id]: { success: false, message: 'Test request failed' },
}));
} finally {
setTestingId(null);
setTimeout(() => {
setTestResults((prev) => {
const next = { ...prev };
delete next[config.id];
return next;
});
}, 5000);
}
};
const handleOpenWizard = () => {
setEditingConfig(null);
setShowWizard(true);
};
const handleCancelWizard = () => {
setShowWizard(false);
setEditingConfig(null);
};
if (showWizard) {
return (
<AuthGuard>
<DashboardLayout>
<div className="max-w-xl mx-auto">
<div className="bg-white rounded-xl shadow-lg p-6">
<NotificationWizard
onComplete={handleWizardComplete}
onCancel={handleCancelWizard}
initialData={
editingConfig
? {
name: editingConfig.name,
channel: editingConfig.channel,
apprise_url: editingConfig.apprise_url,
notify_on_errors: editingConfig.notify_on_errors,
notify_on_success: editingConfig.notify_on_success,
}
: null
}
/>
</div>
</div>
</DashboardLayout>
</AuthGuard>
);
}
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Bell className="h-6 w-6 text-blue-600" />
Notification Channels
</h1>
<p className="mt-1 text-sm text-gray-500">
Get alerts when emails are processed or errors occur.
</p>
</div>
<button
onClick={handleOpenWizard}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4 mr-2" />
Add Channel
</button>
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : notifications && notifications.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{notifications.map((config) => {
const channel = CHANNEL_DISPLAY[config.channel] ?? CHANNEL_DISPLAY.custom;
const testResult = testResults[config.id];
const isTesting = testingId === config.id;
return (
<div
key={config.id}
className={`bg-white rounded-lg shadow border overflow-hidden transition-opacity ${
config.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
}`}
>
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-2xl flex-shrink-0">{channel.icon}</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
{config.name}
</h3>
<span
className={`inline-block mt-0.5 px-2 py-0.5 rounded-full text-xs font-medium ${channel.color}`}
>
{channel.label}
</span>
</div>
</div>
<button
onClick={() =>
toggleMutation.mutate({
id: config.id,
is_enabled: !config.is_enabled,
})
}
disabled={toggleMutation.isPending}
title={config.is_enabled ? 'Disable' : 'Enable'}
className={`ml-2 flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
config.is_enabled
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
>
{config.is_enabled ? (
<CheckCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{config.is_enabled ? 'On' : 'Off'}
</button>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{config.notify_on_errors && (
<span className="px-2 py-0.5 rounded text-xs bg-red-50 text-red-700 border border-red-200">
On Errors
</span>
)}
{config.notify_on_success && (
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 border border-green-200">
On Success
</span>
)}
{!config.notify_on_errors && !config.notify_on_success && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-50 text-gray-500 border border-gray-200">
No triggers set
</span>
)}
</div>
{testResult && (
<div
className={`mb-3 p-2 rounded text-xs flex items-center gap-1.5 ${
testResult.success
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
{testResult.success ? (
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 flex-shrink-0" />
)}
{testResult.message}
</div>
)}
<div className="flex items-center gap-2 pt-3 border-t border-gray-100">
<button
onClick={() => handleTest(config)}
disabled={isTesting || !config.apprise_url}
title="Send test notification"
className="flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100 disabled:opacity-50 transition-colors"
>
{isTesting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5 mr-1" />
)}
{isTesting ? '…' : 'Test'}
</button>
<button
onClick={() => handleEdit(config)}
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
Edit
</button>
<button
onClick={() => handleDelete(config.id)}
disabled={deleteMutation.isPending}
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 disabled:opacity-50 transition-colors"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-16 bg-white rounded-lg shadow border border-dashed border-gray-300">
<Bell className="mx-auto h-12 w-12 text-gray-300 mb-3" />
<h3 className="text-base font-semibold text-gray-700 mb-1">
No notification channels yet
</h3>
<p className="text-sm text-gray-500 mb-4 max-w-xs mx-auto">
Add a channel to receive alerts when emails are processed or errors occur.
</p>
<button
onClick={handleOpenWizard}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4 mr-2" />
Add Your First Channel
</button>
</div>
)}
{/* Info box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-900 mb-1">About Notifications</h4>
<p className="text-xs text-blue-700 leading-relaxed">
Notifications are powered by{' '}
<a
href="https://github.com/caronc/apprise"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-900"
>
Apprise
</a>
, which supports 80+ notification services including Telegram, Discord, Slack, email,
and many more. Each channel can be configured independently with different triggers.
</p>
</div>
</div>
</DashboardLayout>
</AuthGuard>
);
}
@@ -15,6 +15,7 @@ import {
Shield, Shield,
Users, Users,
CreditCard, CreditCard,
Bell
FileText, FileText,
Activity Activity
} from 'lucide-react'; } from 'lucide-react';
@@ -37,6 +38,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Mail Accounts', href: '/accounts', icon: Mail }, { name: 'Mail Accounts', href: '/accounts', icon: Mail },
{ name: 'Notifications', href: '/notifications', icon: Bell },
{ name: 'Logs', href: '/logs', icon: FileText }, { name: 'Logs', href: '/logs', icon: FileText },
{ name: 'Settings', href: '/settings', icon: Settings }, { name: 'Settings', href: '/settings', icon: Settings },
]; ];
@@ -0,0 +1,390 @@
'use client';
import { useState } from 'react';
import { ArrowLeft, Bell, Check, Eye, EyeOff, Send } from 'lucide-react';
interface NotificationWizardProps {
onComplete: (config: {
name: string;
channel: string;
apprise_url: string;
notify_on_errors: boolean;
notify_on_success: boolean;
}) => void;
onCancel: () => void;
initialData?: {
name: string;
channel: string;
apprise_url: string | null;
notify_on_errors: boolean;
notify_on_success: boolean;
} | null;
}
const CHANNEL_OPTIONS = [
{ id: 'telegram', icon: '🤖', label: 'Telegram', description: 'Instant messages via Telegram bot' },
{ id: 'discord', icon: '💬', label: 'Discord', description: 'Server notifications via Discord webhook' },
{ id: 'slack', icon: '💼', label: 'Slack', description: 'Team alerts via Slack webhook' },
{ id: 'email', icon: '📧', label: 'Email', description: 'Email notifications via SMTP' },
{ id: 'webhook', icon: '🔗', label: 'Webhook', description: 'POST to any HTTP endpoint' },
{ id: 'custom', icon: '⚙️', label: 'Custom Apprise URL', description: 'Advanced: any supported Apprise format' },
];
interface ChannelField {
key: string;
label: string;
placeholder: string;
type?: 'text' | 'password';
hint?: string;
optional?: boolean;
}
const CHANNEL_FIELDS: Record<string, ChannelField[]> = {
telegram: [
{
key: 'bot_token',
label: 'Bot Token',
placeholder: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw',
hint: 'Get from @BotFather on Telegram',
},
{
key: 'chat_id',
label: 'Chat ID',
placeholder: '12345678',
hint: 'Your Telegram chat or group ID',
},
],
discord: [
{
key: 'webhook_url',
label: 'Discord Webhook URL',
placeholder: 'https://discord.com/api/webhooks/123456789/abcdef...',
hint: 'Paste the full webhook URL from Discord server settings → Integrations',
},
],
slack: [
{
key: 'webhook_url',
label: 'Slack Webhook URL',
placeholder: 'https://hooks.slack.com/services/T00000000/B00000000/XXXX...',
hint: 'Create an Incoming Webhook in your Slack app settings',
},
],
email: [
{ key: 'username', label: 'Username / Email', placeholder: 'user@example.com' },
{ key: 'password', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
{ key: 'host', label: 'SMTP Host', placeholder: 'smtp.example.com' },
{ key: 'port', label: 'SMTP Port', placeholder: '587', optional: true },
],
webhook: [
{
key: 'url',
label: 'Webhook URL',
placeholder: 'https://hooks.example.com/...',
hint: 'Full HTTP(S) URL — receives a JSON POST with notification data',
},
],
custom: [
{
key: 'apprise_url',
label: 'Apprise URL',
placeholder: 'tgram://bot_token/chat_id/',
hint: 'Any valid Apprise notification URL — see apprise.readthedocs.io',
},
],
};
function buildAppriseUrl(channel: string, fields: Record<string, string>): string {
switch (channel) {
case 'telegram':
if (!fields.bot_token || !fields.chat_id) return '';
return `tgram://${fields.bot_token}/${fields.chat_id}/`;
case 'discord': {
const match = (fields.webhook_url ?? '').match(
/discord\.com\/api\/webhooks\/(\d+)\/([^/?]+)/
);
if (match) return `discord://${match[1]}/${match[2]}/`;
return '';
}
case 'slack': {
const match = (fields.webhook_url ?? '').match(
/hooks\.slack\.com\/services\/([^/]+)\/([^/]+)\/([^/?]+)/
);
if (match) return `slack://${match[1]}/${match[2]}/${match[3]}/`;
return '';
}
case 'email':
if (!fields.username || !fields.password || !fields.host) return '';
return `mailto://${encodeURIComponent(fields.username)}:${encodeURIComponent(fields.password)}@${fields.host}${
fields.port ? `:${fields.port}` : ''
}`;
case 'webhook':
return fields.url || '';
case 'custom':
return fields.apprise_url || '';
default:
return '';
}
}
export function NotificationWizard({ onComplete, onCancel, initialData }: NotificationWizardProps) {
const [step, setStep] = useState<1 | 2 | 3>(initialData ? 3 : 1);
const [selectedChannel, setSelectedChannel] = useState<string>(initialData?.channel ?? '');
const [fields, setFields] = useState<Record<string, string>>({});
const [name, setName] = useState(initialData?.name ?? '');
const [notifyOnErrors, setNotifyOnErrors] = useState(initialData?.notify_on_errors ?? true);
const [notifyOnSuccess, setNotifyOnSuccess] = useState(initialData?.notify_on_success ?? false);
const [showUrl, setShowUrl] = useState(false);
const builtUrl = buildAppriseUrl(selectedChannel, fields);
const effectiveUrl = builtUrl || initialData?.apprise_url || '';
const handleChannelSelect = (channelId: string) => {
setSelectedChannel(channelId);
setFields({});
setStep(2);
};
const handleFieldChange = (key: string, value: string) => {
setFields((prev) => ({ ...prev, [key]: value }));
};
const canProceedStep2 = () => {
const hasAnyField = Object.values(fields).some((v) => v.trim());
if (initialData?.apprise_url && !hasAnyField) return true;
const channelFields = CHANNEL_FIELDS[selectedChannel] ?? [];
return channelFields.every((f) => f.optional || (fields[f.key] ?? '').trim().length > 0);
};
const handleSubmit = () => {
if (!effectiveUrl || !name.trim()) return;
onComplete({
name: name.trim(),
channel: selectedChannel,
apprise_url: effectiveUrl,
notify_on_errors: notifyOnErrors,
notify_on_success: notifyOnSuccess,
});
};
// ── Step 1: Choose channel ─────────────────────────────────────────────
if (step === 1) {
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Bell className="h-5 w-5 text-blue-600" />
Choose Notification Channel
</h3>
<p className="text-sm text-gray-500 mt-1">Select how you want to receive alerts.</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{CHANNEL_OPTIONS.map((channel) => (
<button
key={channel.id}
type="button"
onClick={() => handleChannelSelect(channel.id)}
className="flex flex-col items-start gap-2 p-4 rounded-lg border-2 border-gray-200 hover:border-blue-400 hover:bg-blue-50 transition-colors text-left"
>
<span className="text-2xl">{channel.icon}</span>
<div>
<p className="text-sm font-semibold text-gray-900">{channel.label}</p>
<p className="text-xs text-gray-500 mt-0.5 leading-snug">{channel.description}</p>
</div>
</button>
))}
</div>
<button
type="button"
onClick={onCancel}
className="w-full py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
Cancel
</button>
</div>
);
}
// ── Step 2: Fill in fields ─────────────────────────────────────────────
if (step === 2) {
const channelOption = CHANNEL_OPTIONS.find((c) => c.id === selectedChannel);
const channelFields = CHANNEL_FIELDS[selectedChannel] ?? [];
return (
<div className="space-y-4">
<button
type="button"
onClick={() => {
setStep(1);
setFields({});
}}
className="flex items-center text-sm text-blue-600 hover:text-blue-800"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to channel selection
</button>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3">
<span className="text-2xl">{channelOption?.icon}</span>
<div>
<p className="font-semibold text-blue-900">{channelOption?.label}</p>
<p className="text-xs text-blue-700">{channelOption?.description}</p>
</div>
</div>
{initialData?.apprise_url && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
Leave all fields blank to keep the existing URL unchanged.
</p>
)}
<div className="space-y-3">
{channelFields.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
{field.optional && (
<span className="text-gray-400 font-normal ml-1">(optional)</span>
)}
</label>
<input
type={field.type === 'password' ? 'password' : 'text'}
value={fields[field.key] ?? ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
placeholder={field.placeholder}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{field.hint && <p className="text-xs text-gray-500 mt-1">{field.hint}</p>}
</div>
))}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onCancel}
className="flex-1 py-2 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={() => setStep(3)}
disabled={!canProceedStep2()}
className="flex-1 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
Next
<Send className="h-4 w-4" />
</button>
</div>
</div>
);
}
// ── Step 3: Preview + preferences ─────────────────────────────────────
return (
<div className="space-y-4">
<button
type="button"
onClick={() => setStep(selectedChannel ? 2 : 1)}
className="flex items-center text-sm text-blue-600 hover:text-blue-800"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to configuration
</button>
<div>
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Check className="h-5 w-5 text-green-600" />
Final Setup
</h3>
<p className="text-sm text-gray-500 mt-1">
Name your channel and set notification preferences.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Channel Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. My Telegram Alert"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{effectiveUrl && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-medium text-gray-600">Apprise URL</p>
<button
type="button"
onClick={() => setShowUrl((v) => !v)}
className="text-gray-400 hover:text-gray-600"
aria-label={showUrl ? 'Hide URL' : 'Show URL'}
>
{showUrl ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p
className={`text-xs font-mono break-all ${
showUrl ? 'text-gray-800' : 'text-gray-400 select-none'
}`}
>
{showUrl ? effectiveUrl : '•'.repeat(effectiveUrl.length)}
</p>
</div>
)}
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">Notify me when:</p>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={notifyOnErrors}
onChange={(e) => setNotifyOnErrors(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900">Email processing errors occur</p>
<p className="text-xs text-gray-500">Get alerted when emails fail to forward</p>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={notifyOnSuccess}
onChange={(e) => setNotifyOnSuccess(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900">Emails are successfully forwarded</p>
<p className="text-xs text-gray-500">Get a notification for each successful batch</p>
</div>
</label>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className="flex-1 py-2 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!name.trim() || !effectiveUrl}
className="flex-1 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
<Check className="h-4 w-4" />
Save Channel
</button>
</div>
</div>
);
}
+131
View File
@@ -624,4 +624,135 @@ export const adminApi = {
}, },
}; };
// ── Notification Types ──────────────────────────────────────────────────
export interface NotificationConfig {
id: number;
user_id: number;
name: string;
channel: string;
apprise_url: string | null;
is_enabled: boolean;
config: Record<string, unknown>;
notify_on_errors: boolean;
notify_on_success: boolean;
notify_threshold: number;
created_at: string;
updated_at: string;
}
export interface NotificationConfigCreate {
name: string;
channel: string;
apprise_url?: string | null;
is_enabled?: boolean;
config?: Record<string, unknown>;
notify_on_errors?: boolean;
notify_on_success?: boolean;
notify_threshold?: number;
}
export interface NotificationConfigUpdate {
name?: string;
channel?: string;
apprise_url?: string | null;
is_enabled?: boolean;
config?: Record<string, unknown>;
notify_on_errors?: boolean;
notify_on_success?: boolean;
notify_threshold?: number;
}
export interface AdminNotificationConfig {
id: number;
name: string;
apprise_url: string;
is_enabled: boolean;
notify_on_errors: boolean;
notify_on_system_events: boolean;
description: string | null;
created_at: string;
updated_at: string;
}
export interface AdminNotificationConfigCreate {
name: string;
apprise_url: string;
is_enabled?: boolean;
notify_on_errors?: boolean;
notify_on_system_events?: boolean;
description?: string | null;
}
export interface AdminNotificationConfigUpdate {
name?: string;
apprise_url?: string;
is_enabled?: boolean;
notify_on_errors?: boolean;
notify_on_system_events?: boolean;
description?: string | null;
}
// ── Notifications API ───────────────────────────────────────────────────
export const notificationsApi = {
async list(): Promise<NotificationConfig[]> {
const response = await api.get<NotificationConfig[]>('/notifications');
return response.data;
},
async create(data: NotificationConfigCreate): Promise<NotificationConfig> {
const response = await api.post<NotificationConfig>('/notifications', data);
return response.data;
},
async update(id: number, data: NotificationConfigUpdate): Promise<NotificationConfig> {
const response = await api.put<NotificationConfig>(`/notifications/${id}`, data);
return response.data;
},
async delete(id: number): Promise<void> {
await api.delete(`/notifications/${id}`);
},
async test(apprise_url: string): Promise<{ success: boolean; message: string }> {
const response = await api.post<{ success: boolean; message: string }>(
'/notifications/test',
{ apprise_url }
);
return response.data;
},
};
// ── Admin Notifications API ─────────────────────────────────────────────
export const adminNotificationsApi = {
async list(): Promise<AdminNotificationConfig[]> {
const response = await api.get<AdminNotificationConfig[]>('/admin/notifications');
return response.data;
},
async create(data: AdminNotificationConfigCreate): Promise<AdminNotificationConfig> {
const response = await api.post<AdminNotificationConfig>('/admin/notifications', data);
return response.data;
},
async update(id: number, data: AdminNotificationConfigUpdate): Promise<AdminNotificationConfig> {
const response = await api.put<AdminNotificationConfig>(`/admin/notifications/${id}`, data);
return response.data;
},
async delete(id: number): Promise<void> {
await api.delete(`/admin/notifications/${id}`);
},
async test(apprise_url: string): Promise<{ success: boolean; message: string }> {
const response = await api.post<{ success: boolean; message: string }>(
'/admin/notifications/test',
{ apprise_url }
);
return response.data;
},
};
export default api; export default api;