Browse Source

Removed PWA P´push notification

maziggy 5 months ago
parent
commit
ea6d84031a

+ 0 - 306
backend/app/api/routes/push.py

@@ -1,306 +0,0 @@
-"""API routes for Web Push notifications."""
-
-import base64
-import json
-import logging
-from datetime import datetime
-
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric import ec
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from backend.app.core.database import get_db
-from backend.app.models.notification import PushSubscription
-from backend.app.models.settings import Settings
-from backend.app.schemas.notification import (
-    PushSubscriptionCreate,
-    PushSubscriptionResponse,
-    PushSubscriptionUpdate,
-    VapidPublicKeyResponse,
-)
-
-logger = logging.getLogger(__name__)
-router = APIRouter(prefix="/push", tags=["push"])
-
-# Settings keys for VAPID
-VAPID_PRIVATE_KEY = "vapid_private_key"
-VAPID_PUBLIC_KEY = "vapid_public_key"
-VAPID_CLAIMS_EMAIL = "vapid_claims_email"
-
-
-def _generate_vapid_keys() -> tuple[str, str]:
-    """Generate VAPID key pair using cryptography library."""
-    # Generate private key
-    private_key = ec.generate_private_key(ec.SECP256R1())
-
-    # Get private key in PEM format
-    private_pem = private_key.private_bytes(
-        encoding=serialization.Encoding.PEM,
-        format=serialization.PrivateFormat.PKCS8,
-        encryption_algorithm=serialization.NoEncryption()
-    ).decode("utf-8")
-
-    # Get public key in uncompressed point format (X9.62)
-    public_key = private_key.public_key()
-    public_bytes = public_key.public_bytes(
-        encoding=serialization.Encoding.X962,
-        format=serialization.PublicFormat.UncompressedPoint
-    )
-
-    # Convert to URL-safe base64 (no padding)
-    public_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode('ascii')
-
-    return private_pem, public_b64
-
-
-async def get_or_create_vapid_keys(db: AsyncSession) -> tuple[str, str]:
-    """Get existing VAPID keys or generate new ones."""
-    # Try to get existing keys
-    result = await db.execute(
-        select(Settings).where(Settings.key.in_([VAPID_PRIVATE_KEY, VAPID_PUBLIC_KEY]))
-    )
-    settings = {s.key: s.value for s in result.scalars().all()}
-
-    if VAPID_PRIVATE_KEY in settings and VAPID_PUBLIC_KEY in settings:
-        return settings[VAPID_PRIVATE_KEY], settings[VAPID_PUBLIC_KEY]
-
-    # Generate new keys
-    logger.info("Generating new VAPID keys for Web Push")
-    private_key, public_key = _generate_vapid_keys()
-
-    # Store keys in database
-    for key, value in [(VAPID_PRIVATE_KEY, private_key), (VAPID_PUBLIC_KEY, public_key)]:
-        existing = await db.execute(select(Settings).where(Settings.key == key))
-        setting = existing.scalar_one_or_none()
-        if setting:
-            setting.value = value
-        else:
-            db.add(Settings(key=key, value=value))
-
-    await db.commit()
-    logger.info("VAPID keys generated and stored")
-
-    return private_key, public_key
-
-
-async def get_vapid_claims_email(db: AsyncSession) -> str:
-    """Get the email for VAPID claims (defaults to a placeholder)."""
-    result = await db.execute(select(Settings).where(Settings.key == VAPID_CLAIMS_EMAIL))
-    setting = result.scalar_one_or_none()
-    return setting.value if setting else "mailto:bambuddy@localhost"
-
-
-@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
-async def get_vapid_public_key(db: AsyncSession = Depends(get_db)):
-    """Get the VAPID public key for push subscription."""
-    _, public_key = await get_or_create_vapid_keys(db)
-    return VapidPublicKeyResponse(public_key=public_key)
-
-
-@router.get("/subscriptions", response_model=list[PushSubscriptionResponse])
-async def list_subscriptions(db: AsyncSession = Depends(get_db)):
-    """List all push subscriptions."""
-    result = await db.execute(
-        select(PushSubscription).order_by(PushSubscription.created_at.desc())
-    )
-    return result.scalars().all()
-
-
-@router.post("/subscribe", response_model=PushSubscriptionResponse)
-async def subscribe(
-    subscription: PushSubscriptionCreate,
-    db: AsyncSession = Depends(get_db),
-):
-    """Subscribe a browser to push notifications."""
-    # Check if subscription already exists (by endpoint)
-    result = await db.execute(
-        select(PushSubscription).where(PushSubscription.endpoint == subscription.endpoint)
-    )
-    existing = result.scalar_one_or_none()
-
-    if existing:
-        # Update existing subscription
-        existing.p256dh_key = subscription.p256dh_key
-        existing.auth_key = subscription.auth_key
-        existing.user_agent = subscription.user_agent
-        if subscription.name:
-            existing.name = subscription.name
-        existing.enabled = True
-        existing.updated_at = datetime.utcnow()
-        await db.commit()
-        await db.refresh(existing)
-        logger.info(f"Updated push subscription: {existing.name or existing.id}")
-        return existing
-
-    # Create new subscription
-    # Generate a default name from user agent if not provided
-    name = subscription.name
-    if not name and subscription.user_agent:
-        # Extract browser name from user agent
-        ua = subscription.user_agent.lower()
-        if "chrome" in ua and "edg" not in ua:
-            name = "Chrome"
-        elif "firefox" in ua:
-            name = "Firefox"
-        elif "safari" in ua and "chrome" not in ua:
-            name = "Safari"
-        elif "edg" in ua:
-            name = "Edge"
-        else:
-            name = "Browser"
-
-        # Add device hint
-        if "mobile" in ua or "android" in ua or "iphone" in ua:
-            name += " (Mobile)"
-        else:
-            name += " (Desktop)"
-
-    new_subscription = PushSubscription(
-        endpoint=subscription.endpoint,
-        p256dh_key=subscription.p256dh_key,
-        auth_key=subscription.auth_key,
-        name=name,
-        user_agent=subscription.user_agent,
-        enabled=True,
-    )
-    db.add(new_subscription)
-    await db.commit()
-    await db.refresh(new_subscription)
-
-    logger.info(f"New push subscription created: {new_subscription.name or new_subscription.id}")
-    return new_subscription
-
-
-@router.patch("/subscriptions/{subscription_id}", response_model=PushSubscriptionResponse)
-async def update_subscription(
-    subscription_id: int,
-    update: PushSubscriptionUpdate,
-    db: AsyncSession = Depends(get_db),
-):
-    """Update a push subscription."""
-    result = await db.execute(
-        select(PushSubscription).where(PushSubscription.id == subscription_id)
-    )
-    subscription = result.scalar_one_or_none()
-
-    if not subscription:
-        raise HTTPException(status_code=404, detail="Subscription not found")
-
-    if update.name is not None:
-        subscription.name = update.name
-    if update.enabled is not None:
-        subscription.enabled = update.enabled
-
-    subscription.updated_at = datetime.utcnow()
-    await db.commit()
-    await db.refresh(subscription)
-
-    return subscription
-
-
-@router.delete("/subscriptions/{subscription_id}")
-async def delete_subscription(
-    subscription_id: int,
-    db: AsyncSession = Depends(get_db),
-):
-    """Delete a push subscription."""
-    result = await db.execute(
-        select(PushSubscription).where(PushSubscription.id == subscription_id)
-    )
-    subscription = result.scalar_one_or_none()
-
-    if not subscription:
-        raise HTTPException(status_code=404, detail="Subscription not found")
-
-    await db.delete(subscription)
-    await db.commit()
-
-    return {"message": "Subscription deleted"}
-
-
-@router.post("/unsubscribe")
-async def unsubscribe_by_endpoint(
-    endpoint: str,
-    db: AsyncSession = Depends(get_db),
-):
-    """Unsubscribe by endpoint URL (called when browser unsubscribes)."""
-    result = await db.execute(
-        select(PushSubscription).where(PushSubscription.endpoint == endpoint)
-    )
-    subscription = result.scalar_one_or_none()
-
-    if subscription:
-        await db.delete(subscription)
-        await db.commit()
-        logger.info(f"Push subscription removed by endpoint: {subscription.name or subscription.id}")
-
-    return {"message": "Unsubscribed"}
-
-
-@router.post("/test")
-async def test_push_notification(db: AsyncSession = Depends(get_db)):
-    """Send a test push notification to all subscribed browsers."""
-    from pywebpush import webpush, WebPushException
-
-    private_key, public_key = await get_or_create_vapid_keys(db)
-    claims_email = await get_vapid_claims_email(db)
-
-    # Get all enabled subscriptions
-    result = await db.execute(
-        select(PushSubscription).where(PushSubscription.enabled == True)
-    )
-    subscriptions = result.scalars().all()
-
-    if not subscriptions:
-        raise HTTPException(status_code=400, detail="No push subscriptions found")
-
-    success_count = 0
-    error_count = 0
-    errors = []
-
-    for sub in subscriptions:
-        subscription_info = {
-            "endpoint": sub.endpoint,
-            "keys": {
-                "p256dh": sub.p256dh_key,
-                "auth": sub.auth_key,
-            },
-        }
-
-        payload = json.dumps({
-            "title": "BamBuddy Test",
-            "body": "Push notifications are working!",
-            "url": "/",
-        })
-
-        try:
-            webpush(
-                subscription_info=subscription_info,
-                data=payload,
-                vapid_private_key=private_key,
-                vapid_claims={"sub": claims_email},
-            )
-            sub.last_success = datetime.utcnow()
-            success_count += 1
-            logger.info(f"Test push sent to: {sub.name or sub.id}")
-        except WebPushException as e:
-            error_count += 1
-            sub.last_error = str(e)
-            sub.last_error_at = datetime.utcnow()
-            errors.append(f"{sub.name or sub.id}: {str(e)}")
-            logger.error(f"Push error for {sub.name or sub.id}: {e}")
-
-            # If subscription is gone (410), remove it
-            if e.response and e.response.status_code == 410:
-                await db.delete(sub)
-                logger.info(f"Removed expired subscription: {sub.name or sub.id}")
-
-    await db.commit()
-
-    return {
-        "success": success_count > 0,
-        "message": f"Sent to {success_count} device(s), {error_count} error(s)",
-        "errors": errors if errors else None,
-    }

+ 1 - 2
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_, delete
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook, ams_history, push
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook, ams_history
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -1225,7 +1225,6 @@ app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
-app.include_router(push.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 1 - 2
backend/app/models/__init__.py

@@ -6,7 +6,7 @@ from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.notification_template import NotificationTemplate
-from backend.app.models.notification import NotificationLog, PushSubscription
+from backend.app.models.notification import NotificationLog
 from backend.app.models.project import Project
 from backend.app.models.api_key import APIKey
 from backend.app.models.ams_history import AMSSensorHistory
@@ -23,7 +23,6 @@ __all__ = [
     "KProfileNote",
     "NotificationTemplate",
     "NotificationLog",
-    "PushSubscription",
     "Project",
     "APIKey",
     "AMSSensorHistory",

+ 0 - 27
backend/app/models/notification.py

@@ -101,30 +101,3 @@ class NotificationProvider(Base):
     printer = relationship("Printer", back_populates="notification_providers")
     logs = relationship("NotificationLog", back_populates="provider", cascade="all, delete-orphan")
     digest_queue = relationship("NotificationDigestQueue", back_populates="provider", cascade="all, delete-orphan")
-
-
-class PushSubscription(Base):
-    """Model for storing Web Push subscriptions (browser endpoints)."""
-
-    __tablename__ = "push_subscriptions"
-
-    id = Column(Integer, primary_key=True, index=True)
-
-    # Subscription data from browser's PushManager
-    endpoint = Column(Text, nullable=False, unique=True)  # Push service URL
-    p256dh_key = Column(String(255), nullable=False)  # Public key for encryption
-    auth_key = Column(String(255), nullable=False)  # Auth secret
-
-    # User-friendly name for this subscription
-    name = Column(String(100), nullable=True)  # e.g., "Chrome on Phone", "Firefox Desktop"
-    user_agent = Column(String(255), nullable=True)  # Browser user agent for identification
-
-    # Status
-    enabled = Column(Boolean, default=True)
-    last_success = Column(DateTime, nullable=True)
-    last_error = Column(Text, nullable=True)
-    last_error_at = Column(DateTime, nullable=True)
-
-    # Timestamps
-    created_at = Column(DateTime, default=datetime.utcnow)
-    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 2 - 42
backend/app/schemas/notification.py

@@ -15,7 +15,8 @@ class ProviderType(str, Enum):
     PUSHOVER = "pushover"
     TELEGRAM = "telegram"
     EMAIL = "email"
-    WEBPUSH = "webpush"
+    DISCORD = "discord"
+    WEBHOOK = "webhook"
 
 
 class NotificationProviderBase(BaseModel):
@@ -216,44 +217,3 @@ class NotificationLogStats(BaseModel):
     failure_count: int
     by_event_type: dict[str, int]
     by_provider: dict[str, int]
-
-
-# Push Subscription schemas
-class PushSubscriptionCreate(BaseModel):
-    """Schema for creating a push subscription."""
-
-    endpoint: str = Field(..., description="Push service endpoint URL")
-    p256dh_key: str = Field(..., description="P-256 public key for encryption")
-    auth_key: str = Field(..., description="Authentication secret")
-    name: str | None = Field(default=None, max_length=100, description="User-friendly name")
-    user_agent: str | None = Field(default=None, max_length=255, description="Browser user agent")
-
-
-class PushSubscriptionResponse(BaseModel):
-    """Schema for push subscription API responses."""
-
-    id: int
-    endpoint: str
-    name: str | None
-    user_agent: str | None
-    enabled: bool
-    last_success: datetime | None
-    last_error: str | None
-    last_error_at: datetime | None
-    created_at: datetime
-
-    class Config:
-        from_attributes = True
-
-
-class PushSubscriptionUpdate(BaseModel):
-    """Schema for updating a push subscription."""
-
-    name: str | None = Field(default=None, max_length=100)
-    enabled: bool | None = None
-
-
-class VapidPublicKeyResponse(BaseModel):
-    """Schema for VAPID public key response."""
-
-    public_key: str = Field(..., description="VAPID public key for push subscription")

+ 1 - 90
backend/app/services/notification_service.py

@@ -12,11 +12,10 @@ from typing import Any
 from urllib.parse import quote
 
 import httpx
-from pywebpush import webpush, WebPushException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue, PushSubscription
+from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.settings import Settings
 
@@ -159,10 +158,6 @@ class NotificationService:
                 return await self._send_discord(config, title, message)
             elif provider_type == "webhook":
                 return await self._send_webhook(config, title, message)
-            elif provider_type == "webpush":
-                if db is None:
-                    return False, "Database session required for webpush test"
-                return await self._send_webpush(db, title, message)
             else:
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
@@ -385,86 +380,6 @@ class NotificationService:
         except Exception as e:
             return False, f"Webhook error: {str(e)}"
 
-    async def _get_vapid_keys(self, db: AsyncSession) -> tuple[str | None, str | None]:
-        """Get VAPID keys from database settings."""
-        result = await db.execute(
-            select(Settings).where(Settings.key.in_(["vapid_private_key", "vapid_public_key"]))
-        )
-        settings = {s.key: s.value for s in result.scalars().all()}
-        return settings.get("vapid_private_key"), settings.get("vapid_public_key")
-
-    async def _get_vapid_claims_email(self, db: AsyncSession) -> str:
-        """Get the email for VAPID claims."""
-        result = await db.execute(select(Settings).where(Settings.key == "vapid_claims_email"))
-        setting = result.scalar_one_or_none()
-        return setting.value if setting else "mailto:bambuddy@localhost"
-
-    async def _send_webpush(self, db: AsyncSession, title: str, message: str) -> tuple[bool, str]:
-        """Send notification via Web Push to all subscribed browsers."""
-        private_key, public_key = await self._get_vapid_keys(db)
-
-        if not private_key or not public_key:
-            return False, "VAPID keys not configured. Subscribe a browser first."
-
-        claims_email = await self._get_vapid_claims_email(db)
-
-        # Get all enabled push subscriptions
-        result = await db.execute(
-            select(PushSubscription).where(PushSubscription.enabled == True)
-        )
-        subscriptions = list(result.scalars().all())
-
-        if not subscriptions:
-            return False, "No push subscriptions found"
-
-        success_count = 0
-        error_count = 0
-        last_error = ""
-
-        for sub in subscriptions:
-            subscription_info = {
-                "endpoint": sub.endpoint,
-                "keys": {
-                    "p256dh": sub.p256dh_key,
-                    "auth": sub.auth_key,
-                },
-            }
-
-            payload = json.dumps({
-                "title": title,
-                "body": message,
-                "url": "/",
-            })
-
-            try:
-                webpush(
-                    subscription_info=subscription_info,
-                    data=payload,
-                    vapid_private_key=private_key,
-                    vapid_claims={"sub": claims_email},
-                )
-                sub.last_success = datetime.utcnow()
-                success_count += 1
-                logger.debug(f"Web push sent to: {sub.name or sub.id}")
-            except WebPushException as e:
-                error_count += 1
-                sub.last_error = str(e)
-                sub.last_error_at = datetime.utcnow()
-                last_error = str(e)
-                logger.warning(f"Web push error for {sub.name or sub.id}: {e}")
-
-                # If subscription is gone (410), mark it as disabled
-                if e.response and e.response.status_code == 410:
-                    sub.enabled = False
-                    logger.info(f"Disabled expired subscription: {sub.name or sub.id}")
-
-        await db.commit()
-
-        if success_count > 0:
-            return True, f"Sent to {success_count} device(s)"
-        else:
-            return False, f"Failed to send to all {error_count} device(s): {last_error}"
-
     async def _send_to_provider(
         self, provider: NotificationProvider, title: str, message: str, db: AsyncSession | None = None
     ) -> tuple[bool, str]:
@@ -491,10 +406,6 @@ class NotificationService:
                 return await self._send_discord(config, title, message)
             elif provider.provider_type == "webhook":
                 return await self._send_webhook(config, title, message)
-            elif provider.provider_type == "webpush":
-                if db is None:
-                    return False, "Database session required for webpush"
-                return await self._send_webpush(db, title, message)
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:

+ 1 - 60
frontend/src/api/client.ts

@@ -764,7 +764,7 @@ export interface Filament {
 }
 
 // Notification Provider types
-export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'webpush';
+export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook';
 
 export interface NotificationProvider {
   id: number;
@@ -1907,29 +1907,6 @@ export const api = {
   // AMS History
   getAMSHistory: (printerId: number, amsId: number, hours = 24) =>
     request<AMSHistoryResponse>(`/ams-history/${printerId}/${amsId}?hours=${hours}`),
-
-  // Push Subscriptions (Browser Push Notifications)
-  getVapidPublicKey: () => request<VapidPublicKeyResponse>('/push/vapid-public-key'),
-  getPushSubscriptions: () => request<PushSubscription[]>('/push/subscriptions'),
-  subscribePush: (data: PushSubscriptionCreate) =>
-    request<PushSubscription>('/push/subscribe', {
-      method: 'POST',
-      body: JSON.stringify(data),
-    }),
-  updatePushSubscription: (id: number, data: PushSubscriptionUpdate) =>
-    request<PushSubscription>(`/push/subscriptions/${id}`, {
-      method: 'PATCH',
-      body: JSON.stringify(data),
-    }),
-  deletePushSubscription: (id: number) =>
-    request<{ message: string }>(`/push/subscriptions/${id}`, { method: 'DELETE' }),
-  unsubscribePush: (endpoint: string) =>
-    request<{ message: string }>('/push/unsubscribe', {
-      method: 'POST',
-      body: JSON.stringify(endpoint),
-    }),
-  testPushNotification: () =>
-    request<PushTestResponse>('/push/test', { method: 'POST' }),
 };
 
 // AMS History types
@@ -1951,39 +1928,3 @@ export interface AMSHistoryResponse {
   max_temperature: number | null;
   avg_temperature: number | null;
 }
-
-// Push Subscription types
-export interface PushSubscription {
-  id: number;
-  endpoint: string;
-  name: string | null;
-  user_agent: string | null;
-  enabled: boolean;
-  last_success: string | null;
-  last_error: string | null;
-  last_error_at: string | null;
-  created_at: string;
-}
-
-export interface PushSubscriptionCreate {
-  endpoint: string;
-  p256dh_key: string;
-  auth_key: string;
-  name?: string;
-  user_agent?: string;
-}
-
-export interface PushSubscriptionUpdate {
-  name?: string;
-  enabled?: boolean;
-}
-
-export interface VapidPublicKeyResponse {
-  public_key: string;
-}
-
-export interface PushTestResponse {
-  success: boolean;
-  message: string;
-  errors?: string[];
-}

+ 2 - 15
frontend/src/components/AddNotificationModal.tsx

@@ -13,9 +13,8 @@ interface AddNotificationModalProps {
 
 const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
   { value: 'email', label: 'Email', description: 'SMTP email notifications' },
-  { value: 'webpush', label: 'Browser Push', description: 'Push notifications to subscribed browsers (PWA)' },
-  { value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
   { value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
+  { value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
   { value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
   { value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
   { value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
@@ -211,8 +210,6 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false },
           { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false },
         ];
-      case 'webpush':
-        return []; // No config needed - sends to all subscribed browsers
       default:
         return [];
     }
@@ -230,7 +227,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       onClick={onClose}
     >
       <div
-        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8"
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8 max-h-[90vh] overflow-y-auto"
         onClick={(e) => e.stopPropagation()}
       >
         {/* Header */}
@@ -293,16 +290,6 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           {/* Provider-specific configuration */}
           <div className="space-y-3">
             <p className="text-sm text-bambu-gray">Configuration</p>
-            {providerType === 'webpush' && (
-              <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg text-sm">
-                <p className="text-bambu-green font-medium mb-1">No configuration needed</p>
-                <p className="text-bambu-gray">
-                  Browser Push sends notifications to all browsers that have enabled push notifications
-                  in Settings → Notifications. Subscribe browsers using the "Browser Push Notifications"
-                  card before creating this provider.
-                </p>
-              </div>
-            )}
             {configFields.map((field) => (
               <div key={field.key}>
                 <label className="block text-sm text-bambu-gray mb-1">

+ 0 - 466
frontend/src/components/BrowserPushCard.tsx

@@ -1,466 +0,0 @@
-/**
- * Component for managing browser push notification subscriptions.
- */
-
-import { useState, useEffect, useCallback, Component } from 'react';
-import type { ReactNode } from 'react';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { api } from '../api/client';
-import { Card, CardContent, CardHeader } from './Card';
-import { Button } from './Button';
-import { Toggle } from './Toggle';
-
-// Error boundary for the push card
-interface ErrorBoundaryState {
-  hasError: boolean;
-  error?: Error;
-}
-
-class BrowserPushErrorBoundary extends Component<{ children: ReactNode; className?: string }, ErrorBoundaryState> {
-  constructor(props: { children: ReactNode; className?: string }) {
-    super(props);
-    this.state = { hasError: false };
-  }
-
-  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
-    return { hasError: true, error };
-  }
-
-  render() {
-    if (this.state.hasError) {
-      return (
-        <Card className={this.props.className}>
-          <CardHeader>
-            <div className="flex items-center gap-2">
-              <svg className="w-5 h-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
-              </svg>
-              <h3 className="text-white font-medium">Browser Push Notifications</h3>
-            </div>
-          </CardHeader>
-          <CardContent>
-            <p className="text-red-400 text-sm">Failed to load push notification settings.</p>
-            <p className="text-bambu-gray text-xs mt-1">{this.state.error?.message}</p>
-          </CardContent>
-        </Card>
-      );
-    }
-    return this.props.children;
-  }
-}
-
-// Convert base64 URL to Uint8Array (for VAPID key)
-function urlBase64ToUint8Array(base64String: string): Uint8Array {
-  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
-  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
-  const rawData = window.atob(base64);
-  const outputArray = new Uint8Array(rawData.length);
-  for (let i = 0; i < rawData.length; ++i) {
-    outputArray[i] = rawData.charCodeAt(i);
-  }
-  return outputArray;
-}
-
-// Convert ArrayBuffer to base64 URL string
-function arrayBufferToBase64(buffer: ArrayBuffer): string {
-  const bytes = new Uint8Array(buffer);
-  let binary = '';
-  for (let i = 0; i < bytes.byteLength; i++) {
-    binary += String.fromCharCode(bytes[i]);
-  }
-  return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
-}
-
-interface BrowserPushCardProps {
-  className?: string;
-}
-
-function BrowserPushCardInner({ className }: BrowserPushCardProps) {
-  const queryClient = useQueryClient();
-  const [isSubscribed, setIsSubscribed] = useState(false);
-  const [currentEndpoint, setCurrentEndpoint] = useState<string | null>(null);
-  const [permissionState, setPermissionState] = useState<NotificationPermission>('default');
-  const [isLoading, setIsLoading] = useState(true);
-  const [error, setError] = useState<string | null>(null);
-
-  // Check if push notifications are supported
-  // Requires HTTPS (or localhost) and service worker support
-  const isSupported = typeof window !== 'undefined' &&
-    'serviceWorker' in navigator &&
-    'PushManager' in window &&
-    'Notification' in window;
-
-  // Check if running in secure context
-  const isSecureContext = typeof window !== 'undefined' && window.isSecureContext;
-
-  // Fetch VAPID public key
-  const { data: vapidData } = useQuery({
-    queryKey: ['vapid-public-key'],
-    queryFn: api.getVapidPublicKey,
-    enabled: isSupported,
-  });
-
-  // Fetch all subscriptions
-  const { data: subscriptions, isLoading: subscriptionsLoading } = useQuery({
-    queryKey: ['push-subscriptions'],
-    queryFn: api.getPushSubscriptions,
-  });
-
-  // Subscribe mutation
-  const subscribeMutation = useMutation({
-    mutationFn: api.subscribePush,
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['push-subscriptions'] });
-      setIsSubscribed(true);
-    },
-    onError: (err: Error) => {
-      setError(err.message);
-    },
-  });
-
-  // Delete subscription mutation
-  const deleteMutation = useMutation({
-    mutationFn: api.deletePushSubscription,
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['push-subscriptions'] });
-    },
-  });
-
-  // Update subscription mutation
-  const updateMutation = useMutation({
-    mutationFn: ({ id, data }: { id: number; data: { enabled?: boolean; name?: string } }) =>
-      api.updatePushSubscription(id, data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['push-subscriptions'] });
-    },
-  });
-
-  // Test push mutation
-  const testMutation = useMutation({
-    mutationFn: api.testPushNotification,
-  });
-
-  // Check current subscription status
-  const checkSubscription = useCallback(async () => {
-    if (!isSupported) {
-      setIsLoading(false);
-      return;
-    }
-
-    try {
-      // Safely check notification permission
-      if ('Notification' in window && Notification.permission) {
-        setPermissionState(Notification.permission);
-      }
-
-      const registration = await navigator.serviceWorker.ready;
-      const subscription = await registration.pushManager.getSubscription();
-
-      if (subscription) {
-        setCurrentEndpoint(subscription.endpoint);
-        setIsSubscribed(true);
-      } else {
-        setCurrentEndpoint(null);
-        setIsSubscribed(false);
-      }
-    } catch (err) {
-      console.error('Error checking push subscription:', err);
-      setError('Failed to check push status');
-    } finally {
-      setIsLoading(false);
-    }
-  }, [isSupported]);
-
-  useEffect(() => {
-    checkSubscription();
-  }, [checkSubscription]);
-
-  // Subscribe this browser
-  const handleSubscribe = async () => {
-    if (!vapidData?.public_key) {
-      setError('VAPID key not available');
-      return;
-    }
-
-    setError(null);
-    setIsLoading(true);
-
-    try {
-      // Request permission if needed
-      if (Notification.permission === 'default') {
-        const permission = await Notification.requestPermission();
-        setPermissionState(permission);
-        if (permission !== 'granted') {
-          setError('Notification permission denied');
-          setIsLoading(false);
-          return;
-        }
-      } else if (Notification.permission === 'denied') {
-        setError('Notifications are blocked. Please enable them in your browser settings.');
-        setIsLoading(false);
-        return;
-      }
-
-      // Get service worker registration
-      const registration = await navigator.serviceWorker.ready;
-
-      // Subscribe to push
-      const applicationServerKey = urlBase64ToUint8Array(vapidData.public_key);
-      const subscription = await registration.pushManager.subscribe({
-        userVisibleOnly: true,
-        applicationServerKey: applicationServerKey.buffer as ArrayBuffer,
-      });
-
-      // Extract keys
-      const p256dhKey = subscription.getKey('p256dh');
-      const authKey = subscription.getKey('auth');
-
-      if (!p256dhKey || !authKey) {
-        throw new Error('Failed to get subscription keys');
-      }
-
-      // Send to backend
-      await subscribeMutation.mutateAsync({
-        endpoint: subscription.endpoint,
-        p256dh_key: arrayBufferToBase64(p256dhKey),
-        auth_key: arrayBufferToBase64(authKey),
-        user_agent: navigator.userAgent,
-      });
-
-      setCurrentEndpoint(subscription.endpoint);
-    } catch (err) {
-      console.error('Error subscribing to push:', err);
-      setError(err instanceof Error ? err.message : 'Failed to subscribe');
-    } finally {
-      setIsLoading(false);
-    }
-  };
-
-  // Unsubscribe this browser
-  const handleUnsubscribe = async () => {
-    setIsLoading(true);
-    try {
-      const registration = await navigator.serviceWorker.ready;
-      const subscription = await registration.pushManager.getSubscription();
-
-      if (subscription) {
-        await subscription.unsubscribe();
-      }
-
-      // Find and delete from backend
-      if (currentEndpoint && subscriptions) {
-        const sub = subscriptions.find((s) => s.endpoint === currentEndpoint);
-        if (sub) {
-          await deleteMutation.mutateAsync(sub.id);
-        }
-      }
-
-      setIsSubscribed(false);
-      setCurrentEndpoint(null);
-    } catch (err) {
-      console.error('Error unsubscribing:', err);
-      setError(err instanceof Error ? err.message : 'Failed to unsubscribe');
-    } finally {
-      setIsLoading(false);
-    }
-  };
-
-  // Show message if not in secure context (HTTPS required)
-  if (!isSecureContext) {
-    return (
-      <Card className={className}>
-        <CardHeader>
-          <div className="flex items-center gap-2">
-            <svg className="w-5 h-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
-            </svg>
-            <h3 className="text-white font-medium">Browser Push Notifications</h3>
-          </div>
-        </CardHeader>
-        <CardContent className="space-y-2">
-          <p className="text-yellow-400 text-sm">
-            HTTPS required for push notifications.
-          </p>
-          <p className="text-bambu-gray text-xs">
-            Push notifications require a secure connection (HTTPS). To enable this feature,
-            configure HTTPS for your BamBuddy server using a reverse proxy like Caddy or nginx.
-          </p>
-        </CardContent>
-      </Card>
-    );
-  }
-
-  if (!isSupported) {
-    return (
-      <Card className={className}>
-        <CardHeader>
-          <div className="flex items-center gap-2">
-            <svg className="w-5 h-5 text-bambu-gray" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
-            </svg>
-            <h3 className="text-white font-medium">Browser Push Notifications</h3>
-          </div>
-        </CardHeader>
-        <CardContent className="space-y-2">
-          <p className="text-bambu-gray text-sm">
-            Push notifications are not available in this browser.
-          </p>
-          <p className="text-bambu-gray text-xs">
-            To receive push notifications on your phone, install BamBuddy as a PWA:
-            open this page on your mobile device and tap "Add to Home Screen" in your browser menu.
-          </p>
-        </CardContent>
-      </Card>
-    );
-  }
-
-  return (
-    <Card className={className}>
-      <CardHeader>
-        <div className="flex items-center justify-between">
-          <div className="flex items-center gap-2">
-            <svg className="w-5 h-5 text-bambu-green" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
-            </svg>
-            <h3 className="text-white font-medium">Browser Push Notifications</h3>
-          </div>
-          {isSubscribed && (
-            <span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded-full">
-              Subscribed
-            </span>
-          )}
-        </div>
-      </CardHeader>
-      <CardContent className="space-y-4">
-        {/* Current browser subscription */}
-        <div className="space-y-2">
-          <p className="text-bambu-gray text-sm">
-            {isSubscribed
-              ? 'This browser is subscribed to push notifications.'
-              : 'Enable push notifications to receive alerts directly in your browser.'}
-          </p>
-
-          {error && (
-            <div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
-              {error}
-            </div>
-          )}
-
-          {permissionState === 'denied' && (
-            <div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-400 text-sm">
-              Notifications are blocked. Please enable them in your browser settings.
-            </div>
-          )}
-
-          <div className="flex gap-2">
-            {isSubscribed ? (
-              <>
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={handleUnsubscribe}
-                  disabled={isLoading}
-                >
-                  Unsubscribe
-                </Button>
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => testMutation.mutate()}
-                  disabled={testMutation.isPending || subscriptionsLoading}
-                >
-                  {testMutation.isPending ? 'Sending...' : 'Test'}
-                </Button>
-              </>
-            ) : (
-              <Button
-                variant="primary"
-                size="sm"
-                onClick={handleSubscribe}
-                disabled={isLoading || permissionState === 'denied'}
-              >
-                {isLoading ? 'Subscribing...' : 'Enable Notifications'}
-              </Button>
-            )}
-          </div>
-
-          {testMutation.isSuccess && (
-            <p className="text-bambu-green text-sm">{testMutation.data.message}</p>
-          )}
-          {testMutation.isError && (
-            <p className="text-red-400 text-sm">
-              {testMutation.error instanceof Error ? testMutation.error.message : 'Test failed'}
-            </p>
-          )}
-        </div>
-
-        {/* All subscriptions list */}
-        {subscriptions && subscriptions.length > 0 && (
-          <div className="border-t border-bambu-dark pt-4">
-            <h4 className="text-white text-sm font-medium mb-2">All Subscribed Browsers</h4>
-            <div className="space-y-2">
-              {subscriptions.map((sub) => (
-                <div
-                  key={sub.id}
-                  className={`flex items-center justify-between p-2 rounded ${
-                    sub.endpoint === currentEndpoint
-                      ? 'bg-bambu-green/10 border border-bambu-green/30'
-                      : 'bg-bambu-dark-secondary'
-                  }`}
-                >
-                  <div className="flex-1 min-w-0">
-                    <div className="flex items-center gap-2">
-                      <span className="text-white text-sm truncate">
-                        {sub.name || 'Unknown Browser'}
-                      </span>
-                      {sub.endpoint === currentEndpoint && (
-                        <span className="text-xs text-bambu-green">(this browser)</span>
-                      )}
-                    </div>
-                    <span className="text-bambu-gray text-xs">
-                      Added {new Date(sub.created_at).toLocaleDateString()}
-                    </span>
-                  </div>
-                  <div className="flex items-center gap-2">
-                    <Toggle
-                      checked={sub.enabled}
-                      onChange={(checked) =>
-                        updateMutation.mutate({ id: sub.id, data: { enabled: checked } })
-                      }
-                    />
-                    {sub.endpoint !== currentEndpoint && (
-                      <button
-                        onClick={() => deleteMutation.mutate(sub.id)}
-                        className="p-1 text-bambu-gray hover:text-red-400 transition-colors"
-                        title="Remove"
-                      >
-                        <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
-                        </svg>
-                      </button>
-                    )}
-                  </div>
-                </div>
-              ))}
-            </div>
-          </div>
-        )}
-
-        {/* Info text */}
-        <p className="text-bambu-gray text-xs">
-          Browser push notifications are sent directly to your device. To receive notifications
-          for specific events, create a "Browser Push" notification provider below.
-        </p>
-      </CardContent>
-    </Card>
-  );
-}
-
-// Export with error boundary wrapper
-export function BrowserPushCard({ className }: BrowserPushCardProps) {
-  return (
-    <BrowserPushErrorBoundary className={className}>
-      <BrowserPushCardInner className={className} />
-    </BrowserPushErrorBoundary>
-  );
-}

+ 0 - 4
frontend/src/pages/SettingsPage.tsx

@@ -11,7 +11,6 @@ import { NotificationProviderCard } from '../components/NotificationProviderCard
 import { AddNotificationModal } from '../components/AddNotificationModal';
 import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
 import { NotificationLogViewer } from '../components/NotificationLogViewer';
-import { BrowserPushCard } from '../components/BrowserPushCard';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { BackupModal } from '../components/BackupModal';
 import { RestoreModal } from '../components/RestoreModal';
@@ -1270,9 +1269,6 @@ export function SettingsPage() {
               </div>
             </div>
 
-            {/* Browser Push Notifications */}
-            <BrowserPushCard className="mb-4" />
-
             {/* Notification Language Setting */}
             <Card className="mb-4">
               <CardContent className="py-3">

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CB_jd89Z.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DgzHkxFp.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-hLhCA050.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CZyus6A_.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-hLhCA050.css">
+    <script type="module" crossorigin src="/assets/index-DgzHkxFp.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CB_jd89Z.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff