mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Removed PWA P´push notification
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user