Removed PWA P´push notification

This commit is contained in:
maziggy
2025-12-11 11:37:13 +01:00
parent 2f1ac92598
commit ea6d84031a
14 changed files with 400 additions and 1406 deletions
-306
View File
@@ -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
View File
@@ -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
View File
@@ -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",
-27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
-466
View File
@@ -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>
);
}
-4
View File
@@ -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
View File
@@ -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>