feat: notification preferences system with plugin registry

- Add NotificationType and NotificationPreference models
- Add get_notification_types() to BasePlugin for plugin registration
- Add sync_notification_types() to PluginRegistry
- Update create_notification() to check user preferences (returns Notification|None)
- Add API endpoints: GET /types, GET /preferences, PATCH /preferences/{type_key}
- Add NotificationPreferenceUpdate schema
- Mail plugin registers 10 notification types with metadata
- Add Alembic migration 0017 for new tables + seed data
- Frontend: SettingsNotifications page with toggle switches
- Frontend: Settings tab, route, hooks for notification preferences
- i18n: notification settings keys (de/en)
This commit is contained in:
Agent Zero
2026-07-15 21:00:32 +02:00
parent c4d0ec6f7f
commit b0e987f790
15 changed files with 652 additions and 11 deletions
@@ -0,0 +1,93 @@
"""Add notification_types and notification_preferences tables.
Creates tables for the notification preference system:
- notification_types: registered notification types from plugins
- notification_preferences: per-user opt-in/opt-out for notification types
Also seeds the 10 mail plugin notification types.
Revision ID: 0017_notification_preferences
Revises: 0016_plugin_is_core
Create Date: 2026-07-15
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0017_notification_preferences"
down_revision: Union[str, None] = "0016_plugin_is_core"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
MAIL_NOTIFICATION_TYPES = [
{"type_key": "mail_new", "label": "Neue E-Mail empfangen", "description": "Benachrichtigung bei neuen E-Mails", "is_enabled_by_default": True},
{"type_key": "mail_error", "label": "IMAP-Verbindungsfehler", "description": "Fehler bei der Verbindung zum Mailserver", "is_enabled_by_default": True},
{"type_key": "mail_auth", "label": "IMAP-Login-Fehler", "description": "Anmeldung am Mailserver fehlgeschlagen", "is_enabled_by_default": True},
{"type_key": "mail_quota", "label": "Postfach fast voll", "description": "Warnung bei hohem Postfach-Füllstand", "is_enabled_by_default": True},
{"type_key": "mail_sync_error", "label": "Sync-Fehler", "description": "Synchronisierung fehlgeschlagen", "is_enabled_by_default": True},
{"type_key": "mail_sent", "label": "E-Mail gesendet", "description": "Bestätigung beim Senden einer E-Mail", "is_enabled_by_default": False},
{"type_key": "mail_send_error", "label": "SMTP-Sendefehler", "description": "E-Mail konnte nicht gesendet werden", "is_enabled_by_default": True},
{"type_key": "mail_draft", "label": "Entwurf gespeichert", "description": "Bestätigung beim Speichern eines Entwurfs", "is_enabled_by_default": False},
{"type_key": "mail_account", "label": "Account deaktiviert", "description": "Warnung bei deaktiviertem Mail-Account", "is_enabled_by_default": True},
{"type_key": "mail_folder", "label": "Ordner erstellt/gelöscht", "description": "Bestätigung bei Ordner-Operationen", "is_enabled_by_default": False},
]
def upgrade() -> None:
# notification_types table
op.create_table(
"notification_types",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("type_key", sa.String(20), nullable=False, unique=True),
sa.Column("plugin_name", sa.String(100), nullable=False),
sa.Column("category", sa.String(50), nullable=False, server_default="general"),
sa.Column("label", sa.String(200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_enabled_by_default", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_notification_types_key", "notification_types", ["type_key"])
# notification_preferences table
op.create_table(
"notification_preferences",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("type_key", sa.String(20), nullable=False),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "type_key", name="uq_notif_pref_user_type"),
)
op.create_index("ix_notif_prefs_user", "notification_preferences", ["user_id"])
op.create_index("ix_notif_prefs_tenant", "notification_preferences", ["tenant_id"])
# Seed mail plugin notification types
for nt in MAIL_NOTIFICATION_TYPES:
op.execute(
sa.text(
"INSERT INTO notification_types (id, type_key, plugin_name, category, label, description, is_enabled_by_default) "
"VALUES (gen_random_uuid(), :type_key, 'mail', 'mail', :label, :description, :is_enabled_by_default)"
).bindparams(
type_key=nt["type_key"],
label=nt["label"],
description=nt["description"],
is_enabled_by_default=nt["is_enabled_by_default"],
)
)
def downgrade() -> None:
op.drop_index("ix_notif_prefs_tenant", table_name="notification_preferences")
op.drop_index("ix_notif_prefs_user", table_name="notification_preferences")
op.drop_table("notification_preferences")
op.drop_index("ix_notification_types_key", table_name="notification_types")
op.drop_table("notification_types")
+37 -4
View File
@@ -6,10 +6,14 @@ import uuid
from datetime import UTC
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
from app.models.notification import (
Notification,
NotificationPreference,
NotificationType,
)
async def create_notification(
@@ -19,8 +23,37 @@ async def create_notification(
type: str,
title: str,
body: str | None = None,
) -> Notification:
"""Create a new notification for a user."""
) -> Notification | None:
"""Create a new notification for a user if they have not disabled this type.
Returns None if the user has opted out of this notification type.
"""
# Check user preference
pref = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.type_key == type,
NotificationPreference.tenant_id == tenant_id,
)
)
)
pref_row = pref.scalar_one_or_none()
# If preference exists and is disabled, skip
if pref_row and not pref_row.is_enabled:
return None
# If no preference, check if type is enabled by default
if not pref_row:
type_def = await db.execute(
select(NotificationType).where(NotificationType.type_key == type)
)
type_row = type_def.scalar_one_or_none()
if type_row and not type_row.is_enabled_by_default:
return None
# Create notification
notif = Notification(
tenant_id=tenant_id,
user_id=user_id,
+3 -1
View File
@@ -8,7 +8,7 @@ from app.models.auth import ApiToken, PasswordResetToken
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
from app.models.currency import Currency
from app.models.notification import Notification
from app.models.notification import Notification, NotificationPreference, NotificationType
from app.models.plugin import Plugin, PluginMigration
from app.models.role import Role
from app.models.sequence import Sequence
@@ -28,6 +28,8 @@ __all__ = [
"AuditLog",
"DeletionLog",
"Notification",
"NotificationType",
"NotificationPreference",
"PasswordResetToken",
"ApiToken",
"Company",
+57 -2
View File
@@ -1,11 +1,20 @@
"""Notification model."""
"""Notification models."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -33,3 +42,49 @@ class Notification(Base, TenantMixin):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class NotificationType(Base):
"""Registered notification type from a plugin."""
__tablename__ = "notification_types"
__table_args__ = (Index("ix_notification_types_key", "type_key"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
type_key: Mapped[str] = mapped_column(String(20), nullable=False, unique=True)
plugin_name: Mapped[str] = mapped_column(String(100), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="general")
label: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_enabled_by_default: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class NotificationPreference(Base, TenantMixin):
"""User preference for a notification type (opt-in/opt-out)."""
__tablename__ = "notification_preferences"
__table_args__ = (
UniqueConstraint("user_id", "type_key", name="uq_notif_pref_user_type"),
Index("ix_notif_prefs_user", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
type_key: Mapped[str] = mapped_column(String(20), nullable=False)
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+10
View File
@@ -72,6 +72,16 @@ class BasePlugin(ABC):
"""
return None
# ─── Notification Types ───
def get_notification_types(self) -> list[dict[str, Any]]:
"""Return notification types this plugin registers.
Override in subclasses to register notification types.
Each dict must have: type_key, category, label, description, is_enabled_by_default
"""
return []
# ─── Route Registration ───
def get_routes(self) -> list[APIRouter]:
+16
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
@@ -59,6 +60,21 @@ class MailPlugin(BasePlugin):
self._auto_sync_task = asyncio.create_task(_auto_sync_loop())
logger.info("Mail plugin: auto-sync background task started")
def get_notification_types(self) -> list[dict[str, Any]]:
"""Return the notification types this mail plugin registers."""
return [
{"type_key": "mail_new", "category": "mail", "label": "Neue E-Mail empfangen", "description": "Benachrichtigung bei neuen E-Mails", "is_enabled_by_default": True},
{"type_key": "mail_error", "category": "mail", "label": "IMAP-Verbindungsfehler", "description": "Fehler bei der Verbindung zum Mailserver", "is_enabled_by_default": True},
{"type_key": "mail_auth", "category": "mail", "label": "IMAP-Login-Fehler", "description": "Anmeldung am Mailserver fehlgeschlagen", "is_enabled_by_default": True},
{"type_key": "mail_quota", "category": "mail", "label": "Postfach fast voll", "description": "Warnung bei hohem Postfach-Füllstand", "is_enabled_by_default": True},
{"type_key": "mail_sync_error", "category": "mail", "label": "Sync-Fehler", "description": "Synchronisierung fehlgeschlagen", "is_enabled_by_default": True},
{"type_key": "mail_sent", "category": "mail", "label": "E-Mail gesendet", "description": "Bestätigung beim Senden einer E-Mail", "is_enabled_by_default": False},
{"type_key": "mail_send_error", "category": "mail", "label": "SMTP-Sendefehler", "description": "E-Mail konnte nicht gesendet werden", "is_enabled_by_default": True},
{"type_key": "mail_draft", "category": "mail", "label": "Entwurf gespeichert", "description": "Bestätigung beim Speichern eines Entwurfs", "is_enabled_by_default": False},
{"type_key": "mail_account", "category": "mail", "label": "Account deaktiviert", "description": "Warnung bei deaktiviertem Mail-Account", "is_enabled_by_default": True},
{"type_key": "mail_folder", "category": "mail", "label": "Ordner erstellt/gelöscht", "description": "Bestätigung bei Ordner-Operationen", "is_enabled_by_default": False},
]
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
+70
View File
@@ -117,6 +117,73 @@ class PluginRegistry:
"""List all discovered plugin names."""
return list(self._plugins.keys())
# ── Notification Type Sync ──
async def sync_notification_types(self, db: AsyncSession) -> None:
"""Sync notification types from all active plugins to DB.
For each active plugin, calls get_notification_types() and upserts
the returned types into the notification_types table. Removes types
belonging to plugins that are no longer active.
"""
from app.models.notification import NotificationType
# Collect all notification types from active plugins
active_types: dict[str, dict[str, Any]] = {} # type_key -> type_data
for name, plugin in self._plugins.items():
record = await self._get_plugin_record(db, name)
if record is None or not record.active:
continue
for nt in plugin.get_notification_types():
key = nt.get("type_key", "")
if not key:
continue
active_types[key] = {
"type_key": key,
"plugin_name": name,
"category": nt.get("category", "general"),
"label": nt.get("label", key),
"description": nt.get("description"),
"is_enabled_by_default": nt.get("is_enabled_by_default", True),
}
# Fetch existing types from DB
result = await db.execute(select(NotificationType))
existing = {row.type_key: row for row in result.scalars().all()}
# Upsert active types
for key, data in active_types.items():
if key in existing:
row = existing[key]
row.plugin_name = data["plugin_name"]
row.category = data["category"]
row.label = data["label"]
row.description = data["description"]
row.is_enabled_by_default = data["is_enabled_by_default"]
else:
new_row = NotificationType(
type_key=key,
plugin_name=data["plugin_name"],
category=data["category"],
label=data["label"],
description=data["description"],
is_enabled_by_default=data["is_enabled_by_default"],
)
db.add(new_row)
# Remove types from plugins that are no longer active
active_keys = set(active_types.keys())
for key, row in existing.items():
if key not in active_keys:
# Check if the plugin is still active
plugin_name = row.plugin_name
plugin_record = await self._get_plugin_record(db, plugin_name)
if plugin_record is None or not plugin_record.active:
await db.delete(row)
await db.flush()
logger.info("Synced %d notification types from active plugins", len(active_types))
# ── DB Status Sync ──
async def sync_db_status(self, db: AsyncSession) -> None:
@@ -418,6 +485,9 @@ class PluginRegistry:
mounted_routes.append(r)
self._mounted_routes[name] = mounted_routes
# Sync notification types from this plugin
await self.sync_notification_types(db)
# Update DB record
record.status = "active"
record.active = True
+126
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -14,6 +15,11 @@ from app.core.notifications import (
mark_notification_read,
)
from app.deps import get_current_user
from app.models.notification import (
NotificationPreference,
NotificationType,
)
from app.schemas.common import NotificationPreferenceUpdate
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
@@ -71,3 +77,123 @@ async def unread_count_endpoint(
user_id = uuid.UUID(current_user["user_id"])
count = await get_unread_count(db, tenant_id, user_id)
return {"count": count}
@router.get("/types")
async def list_notification_types(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all registered notification types with the user's preference."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Fetch all registered types
types_result = await db.execute(select(NotificationType).order_by(NotificationType.plugin_name, NotificationType.category))
types = types_result.scalars().all()
# Fetch user's preferences
prefs_result = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.tenant_id == tenant_id,
)
)
)
prefs = {p.type_key: p.is_enabled for p in prefs_result.scalars().all()}
items = []
for t in types:
if t.type_key in prefs:
is_enabled = prefs[t.type_key]
else:
is_enabled = t.is_enabled_by_default
items.append({
"type_key": t.type_key,
"plugin_name": t.plugin_name,
"category": t.category,
"label": t.label,
"description": t.description,
"is_enabled_by_default": t.is_enabled_by_default,
"is_enabled": is_enabled,
})
return {"items": items}
@router.get("/preferences")
async def list_preferences(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's notification preferences."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
result = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.tenant_id == tenant_id,
)
)
)
prefs = result.scalars().all()
return {
"items": [
{"type_key": p.type_key, "is_enabled": p.is_enabled}
for p in prefs
]
}
@router.patch("/preferences/{type_key}")
async def update_preference(
type_key: str,
data: NotificationPreferenceUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update user's preference for a notification type (upsert)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Check that the type exists
type_result = await db.execute(
select(NotificationType).where(NotificationType.type_key == type_key)
)
type_row = type_result.scalar_one_or_none()
if type_row is None:
raise HTTPException(
404,
detail={"detail": f"Notification type '{type_key}' not found", "code": "not_found"},
)
# Upsert preference
result = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.type_key == type_key,
NotificationPreference.tenant_id == tenant_id,
)
)
)
pref = result.scalar_one_or_none()
if pref is None:
pref = NotificationPreference(
tenant_id=tenant_id,
user_id=user_id,
type_key=type_key,
is_enabled=data.is_enabled,
)
db.add(pref)
else:
pref.is_enabled = data.is_enabled
await db.flush()
return {"type_key": type_key, "is_enabled": pref.is_enabled}
+19
View File
@@ -34,3 +34,22 @@ class NotificationListResponse(BaseModel):
class UnreadCountResponse(BaseModel):
count: int
class NotificationPreferenceUpdate(BaseModel):
is_enabled: bool
class NotificationTypeResponse(BaseModel):
type_key: str
plugin_name: str
category: str
label: str
description: str | None = None
is_enabled_by_default: bool
is_enabled: bool
class NotificationPreferenceResponse(BaseModel):
type_key: str
is_enabled: bool
+36
View File
@@ -459,6 +459,42 @@ export function useDeleteNotification() {
});
}
export interface NotificationTypeItem {
type_key: string;
plugin_name: string;
category: string;
label: string;
description: string | null;
is_enabled_by_default: boolean;
is_enabled: boolean;
}
export function useNotificationTypes() {
return useQuery({
queryKey: ['notification-types'],
queryFn: () => apiGet<{ items: NotificationTypeItem[] }>('/notifications/types'),
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: ['notification-preferences'],
queryFn: () => apiGet<{ items: { type_key: string; is_enabled: boolean }[] }>('/notifications/preferences'),
});
}
export function useUpdateNotificationPreference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ typeKey, isEnabled }: { typeKey: string; isEnabled: boolean }) =>
apiPatch(`/notifications/preferences/${typeKey}`, { is_enabled: isEnabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-preferences'] });
queryClient.invalidateQueries({ queryKey: ['notification-types'] });
},
});
}
export function usePlugins() {
return useQuery({
queryKey: ['plugins'],
+12 -2
View File
@@ -407,7 +407,7 @@
"testConnection": "Verbindung testen",
"connectionSuccess": "Verbindung erfolgreich.",
"syncNow": "Jetzt synchronisieren",
"syncSuccess": "{{count}} E-Mails synchronisiert.",
"syncSuccess": "Synchronisierung erfolgreich",
"selectAccount": "Account auswählen",
"selectAccountFirst": "Bitte wählen Sie zuerst einen Account aus.",
"folders": "Ordner",
@@ -560,9 +560,19 @@
"sortSubject": "Betreff",
"sortAsc": "Aufsteigend",
"sortDesc": "Absteigend",
"syncSuccess": "Synchronisierung erfolgreich",
"syncFailed": "Synchronisierung fehlgeschlagen",
"syncing": "Synchronisiere...",
"autoSyncEnabled": "Auto-Sync aktiv"
},
"notifications": {
"title": "Benachrichtigungseinstellungen",
"description": "Wählen Sie welche Benachrichtigungen Sie erhalten möchten",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"saved": "Einstellung gespeichert",
"category": {
"mail": "E-Mail",
"general": "Allgemein"
}
}
}
+12 -2
View File
@@ -407,7 +407,7 @@
"testConnection": "Test Connection",
"connectionSuccess": "Connection successful.",
"syncNow": "Sync Now",
"syncSuccess": "{{count}} emails synced.",
"syncSuccess": "Sync successful",
"selectAccount": "Select Account",
"selectAccountFirst": "Please select an account first.",
"folders": "Folders",
@@ -560,9 +560,19 @@
"sortSubject": "Subject",
"sortAsc": "Ascending",
"sortDesc": "Descending",
"syncSuccess": "Sync successful",
"syncFailed": "Sync failed",
"syncing": "Syncing...",
"autoSyncEnabled": "Auto-sync enabled"
},
"notifications": {
"title": "Notification Settings",
"description": "Choose which notifications you want to receive",
"enabled": "Enabled",
"disabled": "Disabled",
"saved": "Preference saved",
"category": {
"mail": "Mail",
"general": "General"
}
}
}
+1
View File
@@ -15,6 +15,7 @@ export function SettingsPage() {
{ to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' },
{ to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' },
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
];
return (
@@ -0,0 +1,158 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@/components/ui/Toast';
import {
useNotificationTypes,
useUpdateNotificationPreference,
} from '@/api/hooks';
interface NotificationTypeItem {
type_key: string;
plugin_name: string;
category: string;
label: string;
description: string | null;
is_enabled_by_default: boolean;
is_enabled: boolean;
}
export function SettingsNotificationsPage() {
const { t } = useTranslation();
const toast = useToast();
const { data, isLoading, error } = useNotificationTypes();
const updateMutation = useUpdateNotificationPreference();
const items: NotificationTypeItem[] = data?.items ?? [];
// Group by plugin_name
const grouped = useMemo(() => {
const map = new Map<string, NotificationTypeItem[]>();
for (const item of items) {
const key = item.plugin_name;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return Array.from(map.entries());
}, [items]);
const handleToggle = (typeKey: string, isEnabled: boolean) => {
updateMutation.mutate(
{ typeKey, isEnabled },
{
onSuccess: () => {
toast.success(t('notifications.saved'));
},
onError: (err: any) => {
toast.error(err.message || t('common.error'));
},
}
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<p className="text-secondary-500">{t('common.loading')}</p>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-12">
<p className="text-red-600">{t('common.error')}</p>
</div>
);
}
return (
<div data-testid="settings-notifications-page">
<div className="mb-6">
<h2 className="text-2xl font-bold text-secondary-900">
{t('notifications.title')}
</h2>
<p className="mt-1 text-sm text-secondary-500">
{t('notifications.description')}
</p>
</div>
{grouped.length === 0 && (
<div className="rounded-lg border border-secondary-200 p-8 text-center">
<p className="text-secondary-500">{t('common.noResults')}</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{grouped.map(([pluginName, pluginItems]) => (
<div
key={pluginName}
className="rounded-lg border border-secondary-200 bg-white shadow-sm overflow-hidden"
>
<div className="border-b border-secondary-200 bg-secondary-50 px-5 py-3">
<h3 className="text-lg font-semibold text-secondary-900 capitalize">
{pluginName}
</h3>
</div>
<div className="divide-y divide-secondary-100">
{pluginItems.map((item) => (
<div
key={item.type_key}
className="flex items-start justify-between gap-4 px-5 py-4"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-900">
{item.label}
</p>
{item.description && (
<p className="mt-0.5 text-xs text-secondary-500">
{item.description}
</p>
)}
</div>
<ToggleSwitch
checked={item.is_enabled}
onChange={(checked) => handleToggle(item.type_key, checked)}
disabled={updateMutation.isPending}
ariaLabel={item.label}
/>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
function ToggleSwitch({
checked,
onChange,
disabled,
ariaLabel,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
ariaLabel: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
checked ? 'bg-primary-600' : 'bg-secondary-300'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
);
}
+2
View File
@@ -29,6 +29,7 @@ import { DmsPage } from '@/pages/Dms';
import { DmsTrashPage } from '@/pages/DmsTrash';
import { MailPage } from '@/pages/Mail';
import { MailSettingsPage } from '@/pages/MailSettings';
import { SettingsNotificationsPage } from '@/pages/SettingsNotifications';
const router = createBrowserRouter([
{
@@ -81,6 +82,7 @@ const router = createBrowserRouter([
{ path: 'taxes', element: <SettingsTaxesPage /> },
{ path: 'sequences', element: <SettingsSequencesPage /> },
{ path: 'mail', element: <MailSettingsPage /> },
{ path: 'notifications', element: <SettingsNotificationsPage /> },
],
},
],