Prechádzať zdrojové kódy

Let other applications send messages through the notification channels

POST /notifications/app-message delivers an app's message to every channel
with the new "Messages from connected apps" switch on (off by default),
through quiet hours, the digest and the log. API keys need the new "Send
notifications" permission, and their owner notifications:update; plain text,
http(s) links, 20 messages a minute per key. The electricity-price door and
this one now share one scoped-key check. /queue?batch=<id> opens and
highlights one batch order.
maziggy 1 deň pred
rodič
commit
6855d65d12
41 zmenil súbory, kde vykonal 680 pridanie a 34 odobranie
  1. 2 0
      CHANGELOG.md
  2. 4 0
      backend/app/api/routes/api_keys.py
  3. 69 1
      backend/app/api/routes/notifications.py
  4. 2 1
      backend/app/api/routes/settings.py
  5. 66 24
      backend/app/core/auth.py
  6. 18 0
      backend/app/core/database.py
  7. 4 0
      backend/app/models/api_key.py
  8. 3 0
      backend/app/models/notification.py
  9. 3 0
      backend/app/schemas/api_key.py
  10. 42 0
      backend/app/schemas/notification.py
  11. 24 0
      backend/app/services/notification_service.py
  12. 233 0
      backend/tests/integration/test_notification_app_message.py
  13. 1 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  14. 5 0
      frontend/scripts/check-i18n-parity.mjs
  15. 24 0
      frontend/src/__tests__/components/BatchOrdersView.test.tsx
  16. 6 0
      frontend/src/api/client.ts
  17. 14 0
      frontend/src/components/AddNotificationModal.tsx
  18. 15 4
      frontend/src/components/BatchOrdersView.tsx
  19. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  20. 7 0
      frontend/src/i18n/locales/de.ts
  21. 7 0
      frontend/src/i18n/locales/en.ts
  22. 7 0
      frontend/src/i18n/locales/es.ts
  23. 7 0
      frontend/src/i18n/locales/fr.ts
  24. 7 0
      frontend/src/i18n/locales/it.ts
  25. 7 0
      frontend/src/i18n/locales/ja.ts
  26. 7 0
      frontend/src/i18n/locales/ko.ts
  27. 7 0
      frontend/src/i18n/locales/nl.ts
  28. 7 0
      frontend/src/i18n/locales/pt-BR.ts
  29. 7 0
      frontend/src/i18n/locales/ru.ts
  30. 7 0
      frontend/src/i18n/locales/sv.ts
  31. 7 0
      frontend/src/i18n/locales/tr.ts
  32. 7 0
      frontend/src/i18n/locales/uk.ts
  33. 7 0
      frontend/src/i18n/locales/zh-CN.ts
  34. 7 0
      frontend/src/i18n/locales/zh-TW.ts
  35. 8 1
      frontend/src/pages/QueuePage.tsx
  36. 16 0
      frontend/src/pages/SettingsPage.tsx
  37. 0 1
      static/assets/PdfPreviewModal-HUSkHM8p.js
  38. 0 0
      static/assets/SpreadsheetPreviewModal-DIlPhqnM.js
  39. 1 1
      static/assets/index-C4HAeZyC.js
  40. 0 0
      static/assets/pdf-Cz0mxoII.js
  41. 1 1
      static/index.html

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 2 - 0
CHANGELOG.md


+ 4 - 0
backend/app/api/routes/api_keys.py

@@ -70,6 +70,7 @@ async def create_api_key(
         can_manage_projects=data.can_manage_projects,
         can_access_cloud=data.can_access_cloud,
         can_update_energy_cost=data.can_update_energy_cost,
+        can_send_notifications=data.can_send_notifications,
         printer_ids=data.printer_ids,
         expires_at=data.expires_at,
     )
@@ -94,6 +95,7 @@ async def create_api_key(
         can_manage_projects=api_key.can_manage_projects,
         can_access_cloud=api_key.can_access_cloud,
         can_update_energy_cost=api_key.can_update_energy_cost,
+        can_send_notifications=api_key.can_send_notifications,
         printer_ids=api_key.printer_ids,
         enabled=api_key.enabled,
         last_used=api_key.last_used,
@@ -162,6 +164,8 @@ async def update_api_key(
         api_key.can_access_cloud = data.can_access_cloud
     if data.can_update_energy_cost is not None:
         api_key.can_update_energy_cost = data.can_update_energy_cost
+    if data.can_send_notifications is not None:
+        api_key.can_send_notifications = data.can_send_notifications
     if data.printer_ids is not None:
         api_key.printer_ids = data.printer_ids
     if data.enabled is not None:

+ 69 - 1
backend/app/api/routes/notifications.py

@@ -2,18 +2,23 @@
 
 import json
 import logging
+import time
+from collections import defaultdict, deque
 from datetime import datetime, timedelta, timezone
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, ScopedCaller, require_notification_send
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.notification import NotificationLog, NotificationProvider
 from backend.app.models.user import User
 from backend.app.schemas.notification import (
+    AppMessage,
+    AppMessageChannel,
+    AppMessageResult,
     NotificationLogResponse,
     NotificationLogStats,
     NotificationProviderCreate,
@@ -72,6 +77,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
         "on_first_layer_complete": provider.on_first_layer_complete,
+        # Messages from connected apps
+        "on_app_message": bool(provider.on_app_message),
         # Inventory stock alerts. Absent here, the toggles above always read
         # back off no matter what the row holds — the same hand-maintained
         # field map the Home Assistant comment warns about.
@@ -166,6 +173,7 @@ async def create_notification_provider(
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete
         on_first_layer_complete=provider_data.on_first_layer_complete,
+        on_app_message=provider_data.on_app_message,
         # Inventory stock alerts
         on_stock_reorder_alert=provider_data.on_stock_reorder_alert,
         on_stock_break_alert=provider_data.on_stock_break_alert,
@@ -401,6 +409,66 @@ async def clear_notification_logs(
 # ============================================================================
 
 
+# Messages from other applications -------------------------------------------
+
+# Per caller, in memory: enough for any real app (Bambuddy Orders sends a few a
+# day), and a buggy or hostile one can't flood the channels.
+APP_MESSAGE_LIMIT = 20
+APP_MESSAGE_WINDOW_SECONDS = 60
+_app_message_times: dict[str, deque[float]] = defaultdict(deque)
+
+
+def _app_sender(caller: ScopedCaller) -> tuple[str, str]:
+    """(rate-limit key, name shown in the log) for whoever sends the message."""
+    if caller.api_key is not None:
+        return f"key:{caller.api_key.id}", caller.api_key.name
+    if caller.user is not None:
+        return f"user:{caller.user.id}", caller.user.username
+    return "anonymous", "app"
+
+
+def _check_app_message_rate(key: str) -> None:
+    times = _app_message_times[key]
+    cutoff = time.monotonic() - APP_MESSAGE_WINDOW_SECONDS
+    while times and times[0] < cutoff:
+        times.popleft()
+    if len(times) >= APP_MESSAGE_LIMIT:
+        raise HTTPException(status_code=429, detail="Too many messages; try again in a minute")
+    times.append(time.monotonic())
+
+
+@router.post("/app-message", response_model=AppMessageResult)
+async def send_app_message(
+    data: AppMessage,
+    db: AsyncSession = Depends(get_db),
+    caller: ScopedCaller = Depends(require_notification_send()),
+):
+    """Send a message through every enabled channel that has "Messages from
+    connected apps" on. For other applications, e.g. Bambuddy Orders; an API
+    key needs the "Send notifications" permission."""
+    key, sender = _app_sender(caller)
+    _check_app_message_rate(key)
+    channels = await notification_service.on_app_message(
+        db, sender=sender, title=data.title, message=data.message, url=data.url
+    )
+    return AppMessageResult(channels=channels)
+
+
+@router.get("/app-message/channels", response_model=list[AppMessageChannel])
+async def app_message_channels(
+    db: AsyncSession = Depends(get_db),
+    _: ScopedCaller = Depends(require_notification_send()),
+):
+    """The enabled channels that deliver app messages: names and types only,
+    so an app can tell its user where its messages will arrive."""
+    rows = await db.execute(
+        select(NotificationProvider)
+        .where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
+        .order_by(NotificationProvider.name)
+    )
+    return [AppMessageChannel(name=p.name, provider_type=p.provider_type) for p in rows.scalars()]
+
+
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 async def get_notification_provider(
     provider_id: int,

+ 2 - 1
backend/app/api/routes/settings.py

@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
     RequirePermissionIfAuthEnabled,
+    ScopedCaller,
     caller_is_api_key,
     require_auth_if_enabled,
     require_energy_cost_update,
@@ -409,7 +410,7 @@ class ElectricityPriceUpdate(BaseModel):
 async def update_electricity_price(
     payload: ElectricityPriceUpdate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_energy_cost_update()),
+    _: ScopedCaller = Depends(require_energy_cost_update()),
     _is_api_key: bool = Depends(caller_is_api_key),
 ):
     """Update the per-kWh electricity cost used by the energy-tracking pipeline.

+ 66 - 24
backend/app/core/auth.py

@@ -5,6 +5,7 @@ import os
 import secrets
 import time
 from contextvars import ContextVar
+from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
@@ -480,28 +481,38 @@ def _check_apikey_permissions(
         raise last_failure
 
 
-def require_energy_cost_update():
-    """Dependency for ``POST /settings/electricity-price`` (#1356).
+@dataclass(frozen=True)
+class ScopedCaller:
+    """Who passed a scoped door: an API key, a user, or nobody (auth disabled)."""
 
-    Bypasses the ``_APIKEY_DENIED_PERMISSIONS`` ``SETTINGS_UPDATE`` block for
-    API keys that explicitly opt into ``can_update_energy_cost``. Full
-    ``SETTINGS_UPDATE`` for API keys stays denied — this is a narrowly-scoped
-    door for the Home Assistant dynamic-tariff use case documented in
-    ``wiki/features/energy.md``, not a general settings-write capability.
+    api_key: APIKey | None = None
+    user: User | None = None
+
+
+def require_api_key_scope(
+    scope_attr: str, scope_name: str, user_permission: Permission, *, owner_needs_permission: bool = False
+):
+    """A narrow door for API keys that carry one explicit scope flag.
+
+    For routes an API key may call only when its ``scope_attr`` flag is set,
+    where the matching user permission stays out of the general API-key
+    mapping (``_APIKEY_DENIED_PERMISSIONS`` / the allowlist).
 
     Accepts:
-      * Auth disabled  → always allowed (matches other settings routes)
-      * JWT user with ``SETTINGS_UPDATE`` permission
-      * API key with ``can_update_energy_cost = True``
+      * Auth disabled  → always allowed (matches the other routes)
+      * JWT user with ``user_permission``
+      * API key with ``scope_attr`` set; fails closed when its owner was deactivated,
+        and with ``owner_needs_permission`` also when its owner lacks
+        ``user_permission`` (so a key can't do what its owner may not)
     """
 
     async def permission_checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    ) -> User | None:
+    ) -> ScopedCaller:
         async with async_session() as db:
             if not await is_auth_enabled(db):
-                return None
+                return ScopedCaller()
 
             credentials_exception = HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
@@ -524,18 +535,28 @@ def require_energy_cost_update():
                         detail="Invalid API key",
                         headers={"WWW-Authenticate": "Bearer"},
                     )
-                # Fails closed if the owner has been deactivated. The scope
-                # flag itself is not narrowed against the owner's permissions
-                # the way the general gate is: this door exists precisely
-                # because no user permission maps to it (SETTINGS_UPDATE stays
-                # denied for keys even when the owner is an administrator).
-                await resolve_apikey_owner(db, api_key)
-                if not api_key.can_update_energy_cost:
+                # Fails closed if the owner has been deactivated. For the
+                # energy-cost door the scope flag is deliberately not narrowed
+                # against the owner's permissions: no user permission maps to it
+                # (SETTINGS_UPDATE stays denied for keys even when the owner is
+                # an administrator). Doors that do have a matching user
+                # permission pass ``owner_needs_permission``.
+                owner = await resolve_apikey_owner(db, api_key)
+                if (
+                    owner_needs_permission
+                    and owner is not None
+                    and not owner.has_all_permissions(user_permission.value)
+                ):
                     raise HTTPException(
                         status_code=status.HTTP_403_FORBIDDEN,
-                        detail="API key does not have 'update_energy_cost' permission",
+                        detail=f"The API key's owner lacks the permission: {user_permission.value}",
                     )
-                return None
+                if not getattr(api_key, scope_attr):
+                    raise HTTPException(
+                        status_code=status.HTTP_403_FORBIDDEN,
+                        detail=f"API key does not have '{scope_name}' permission",
+                    )
+                return ScopedCaller(api_key=api_key)
 
             # JWT path
             if credentials is None:
@@ -558,16 +579,37 @@ def require_energy_cost_update():
                 raise credentials_exception
             if not _is_token_fresh(iat, user):
                 raise credentials_exception
-            if not user.has_all_permissions(Permission.SETTINGS_UPDATE.value):
+            if not user.has_all_permissions(user_permission.value):
                 raise HTTPException(
                     status_code=status.HTTP_403_FORBIDDEN,
-                    detail=f"Missing required permissions: {Permission.SETTINGS_UPDATE.value}",
+                    detail=f"Missing required permissions: {user_permission.value}",
                 )
-            return user
+            return ScopedCaller(user=user)
 
     return permission_checker
 
 
+def require_energy_cost_update():
+    """Dependency for ``POST /settings/electricity-price`` (#1356).
+
+    Bypasses the ``_APIKEY_DENIED_PERMISSIONS`` ``SETTINGS_UPDATE`` block for
+    API keys that explicitly opt into ``can_update_energy_cost``. Full
+    ``SETTINGS_UPDATE`` for API keys stays denied — this is a narrowly-scoped
+    door for the Home Assistant dynamic-tariff use case documented in
+    ``wiki/features/energy.md``, not a general settings-write capability.
+    """
+    return require_api_key_scope("can_update_energy_cost", "update_energy_cost", Permission.SETTINGS_UPDATE)
+
+
+def require_notification_send():
+    """Dependency for ``POST /notifications/app-message``: another application
+    sending a message through the channels that accept app messages. API keys
+    need ``can_send_notifications``; users need ``NOTIFICATIONS_UPDATE``."""
+    return require_api_key_scope(
+        "can_send_notifications", "send_notifications", Permission.NOTIFICATIONS_UPDATE, owner_needs_permission=True
+    )
+
+
 # Password hashing
 # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
 # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations

+ 18 - 0
backend/app/core/database.py

@@ -5095,6 +5095,24 @@ async def run_migrations(conn):
         "CREATE UNIQUE INDEX IF NOT EXISTS uq_print_batches_external ON print_batches (external_source, external_ref)",
     )
 
+    # Migration: messages from other applications through the notification
+    # channels. Both flags default off, so no channel starts delivering them and
+    # no existing API key gains the right to send them on upgrade. BOOLEAN
+    # DEFAULT FALSE is accepted by SQLite and PostgreSQL alike. The backfill
+    # covers a table create_all() already gave the column (the ALTER is then
+    # swallowed as a duplicate and existing rows keep NULL; see the stock alert
+    # flags above).
+    await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_app_message BOOLEAN DEFAULT FALSE")
+    await _safe_execute(conn, "ALTER TABLE api_keys ADD COLUMN can_send_notifications BOOLEAN DEFAULT FALSE")
+    async with conn.begin_nested():
+        await conn.execute(
+            text("UPDATE notification_providers SET on_app_message = :off WHERE on_app_message IS NULL"), {"off": False}
+        )
+        await conn.execute(
+            text("UPDATE api_keys SET can_send_notifications = :off WHERE can_send_notifications IS NULL"),
+            {"off": False},
+        )
+
 
 async def _migrate_confirm_prompt_body_template(conn) -> None:
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).

+ 4 - 0
backend/app/models/api_key.py

@@ -51,6 +51,10 @@ class APIKey(Base):
     # granting full SETTINGS_UPDATE (which is denied for API keys because it
     # could rewrite SMTP/LDAP/MQTT credentials).
     can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Send a message through the notification channels that accept app
+    # messages (POST /notifications/app-message). Nothing else: no reading or
+    # changing the channels themselves.
+    can_send_notifications: Mapped[bool] = mapped_column(Boolean, default=False)
 
     # Optional scope limits
     printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers

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

@@ -109,6 +109,9 @@ class NotificationProvider(Base):
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
     on_first_layer_complete = Column(Boolean, default=False)  # First layer finished printing
+    # Messages another application sends through Bambuddy (POST /notifications/app-message),
+    # e.g. Bambuddy Orders' "an order needs you". Off by default: nothing new arrives on upgrade.
+    on_app_message = Column(Boolean, default=False)
 
     # Event triggers - Inventory stock alerts
     on_stock_reorder_alert = Column(Boolean, default=False)  # SKU hits reorder point

+ 3 - 0
backend/app/schemas/api_key.py

@@ -19,6 +19,7 @@ class APIKeyCreate(BaseModel):
     can_manage_projects: bool = True  # Create/update/delete projects + membership (add archives) (#1893)
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
+    can_send_notifications: bool = False  # POST /notifications/app-message only
     printer_ids: list[int] | None = None  # null = all printers
     expires_at: datetime | None = None
 
@@ -37,6 +38,7 @@ class APIKeyUpdate(BaseModel):
     can_manage_projects: bool | None = None
     can_access_cloud: bool | None = None
     can_update_energy_cost: bool | None = None
+    can_send_notifications: bool | None = None
     printer_ids: list[int] | None = None
     enabled: bool | None = None
     expires_at: datetime | None = None
@@ -59,6 +61,7 @@ class APIKeyResponse(BaseModel):
     can_manage_projects: bool
     can_access_cloud: bool
     can_update_energy_cost: bool
+    can_send_notifications: bool
     printer_ids: list[int] | None
     enabled: bool
     last_used: datetime | None

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

@@ -94,6 +94,9 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - First layer complete
     on_first_layer_complete: bool = Field(default=False, description="Notify when first layer completes")
 
+    # Messages from connected apps (POST /notifications/app-message)
+    on_app_message: bool = Field(default=False, description="Deliver messages other applications send")
+
     # Event triggers - Inventory stock alerts
     # Missing from this schema until now, so every payload naming them was
     # dropped silently: the UI's toggles round-tripped as 200 OK and the row
@@ -203,6 +206,9 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - First layer complete
     on_first_layer_complete: bool | None = None
 
+    # Messages from connected apps
+    on_app_message: bool | None = None
+
     # Event triggers - Inventory stock alerts
     on_stock_reorder_alert: bool | None = None
     on_stock_break_alert: bool | None = None
@@ -279,6 +285,42 @@ class NotificationProviderResponse(NotificationProviderBase):
         from_attributes = True
 
 
+class AppMessage(BaseModel):
+    """A message another application sends through Bambuddy's notification channels."""
+
+    title: str = Field(min_length=1, max_length=120)
+    message: str = Field(min_length=1, max_length=2000)
+    url: str | None = Field(default=None, max_length=500, description="A link the message points to (http or https)")
+
+    @field_validator("title", "message")
+    @classmethod
+    def _plain_text(cls, value: str) -> str:
+        # Plain text: no control characters beyond line breaks and tabs.
+        cleaned = "".join(ch for ch in value if ch in "\n\t" or ch.isprintable()).strip()
+        if not cleaned:
+            raise ValueError("must not be empty")
+        return cleaned
+
+    @field_validator("url")
+    @classmethod
+    def _http_url(cls, value: str | None) -> str | None:
+        if value is None or value.strip() == "":
+            return None
+        value = value.strip()
+        if not value.lower().startswith(("http://", "https://")) or any(c.isspace() for c in value):
+            raise ValueError("must be an http or https address")
+        return value
+
+
+class AppMessageResult(BaseModel):
+    channels: int = Field(description="How many channels the message was handed to")
+
+
+class AppMessageChannel(BaseModel):
+    name: str
+    provider_type: str
+
+
 class NotificationTestRequest(BaseModel):
     """Schema for testing notification configuration."""
 

+ 24 - 0
backend/app/services/notification_service.py

@@ -1279,6 +1279,30 @@ class NotificationService:
                     printer_name=printer_name,
                 )
 
+    async def on_app_message(
+        self,
+        db: AsyncSession,
+        *,
+        sender: str,
+        title: str,
+        message: str,
+        url: str | None = None,
+    ) -> int:
+        """A message another application sends through Bambuddy.
+
+        Goes to every enabled channel with "Messages from connected apps" on,
+        through the same path as Bambuddy's own events: quiet hours, the daily
+        digest and the log (whose event type names the sender). The text is
+        the app's own; a link, when given, is appended so every channel type
+        carries it. Returns how many channels it was handed to.
+        """
+        providers = await self._get_providers_for_event(db, "on_app_message")
+        if not providers:
+            return 0
+        body = f"{message}\n{url}" if url else message
+        await self._send_to_providers(providers, title, body, db, event_type=f"app:{sender}"[:50])
+        return len(providers)
+
     async def on_print_start(
         self,
         printer_id: int,

+ 233 - 0
backend/tests/integration/test_notification_app_message.py

@@ -0,0 +1,233 @@
+"""Messages from other applications through the notification channels.
+
+The contract these tests pin:
+
+  ``POST /notifications/app-message`` hands an app's message to every enabled
+  channel with ``on_app_message`` on, and to no other. An API key needs the
+  opt-in ``can_send_notifications`` scope, and its owner the
+  ``notifications:update`` permission (a key can't do what its owner may not).
+  Users need ``notifications:update``. Text is plain, the link http(s) only,
+  and each caller is rate-limited.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes import notifications as notification_routes
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.user import User
+
+URL = "/api/v1/notifications/app-message"
+
+
+@pytest.fixture(autouse=True)
+def _fresh_rate_limit():
+    notification_routes._app_message_times.clear()
+    yield
+    notification_routes._app_message_times.clear()
+
+
+async def _admin_token(client: AsyncClient) -> str:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "notifyadmin",
+            "admin_password": "AdminPass1!",  # pragma: allowlist secret
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": "notifyadmin", "password": "AdminPass1!"},  # pragma: allowlist secret
+    )
+    return login.json()["access_token"]
+
+
+async def _user_id(db: AsyncSession, username: str) -> int:
+    return (await db.execute(select(User).where(User.username == username))).scalar_one().id
+
+
+async def _key(db: AsyncSession, *, owner_id: int | None, allowed: bool, name: str = "Bambuddy Orders") -> str:
+    full_key, key_hash, key_prefix = generate_api_key()
+    db.add(
+        APIKey(name=name, key_hash=key_hash, key_prefix=key_prefix, user_id=owner_id, can_send_notifications=allowed)
+    )
+    await db.commit()
+    return full_key
+
+
+async def _channels(db: AsyncSession) -> None:
+    for name, on, enabled in (
+        ("Telegram", True, True),
+        ("ntfy", True, True),
+        ("Email", False, True),
+        ("Old", True, False),
+    ):
+        db.add(NotificationProvider(name=name, provider_type="ntfy", enabled=enabled, config="{}", on_app_message=on))
+    await db.commit()
+
+
+def _sent():
+    return patch(
+        "backend.app.services.notification_service.notification_service._send_to_provider",
+        new=AsyncMock(return_value=(True, "")),
+    )
+
+
+class TestAppMessage:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_goes_to_the_channels_that_accept_app_messages(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        await _channels(db_session)
+        key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=True)
+
+        with _sent() as send:
+            resp = await async_client.post(
+                URL,
+                headers={"X-API-Key": key},
+                json={
+                    "title": "3 orders need you",
+                    "message": "#1004, #1009, #1010",
+                    "url": "http://orders.lan:8090/todo",
+                },
+            )
+
+        assert resp.status_code == 200, resp.text
+        assert resp.json() == {"channels": 2}
+        names = sorted(call.args[0].name for call in send.await_args_list)
+        assert names == ["Telegram", "ntfy"]
+        _, title, body = send.await_args_list[0].args[:3]
+        assert title == "3 orders need you"
+        assert body == "#1004, #1009, #1010\nhttp://orders.lan:8090/todo"
+        assert send.await_args_list[0].kwargs["event_type"] == "app:Bambuddy Orders"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lists_the_channels_by_name(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        await _channels(db_session)
+        key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=True)
+        resp = await async_client.get(f"{URL}/channels", headers={"X-API-Key": key})
+        assert resp.status_code == 200
+        assert resp.json() == [{"name": "Telegram", "provider_type": "ntfy"}, {"name": "ntfy", "provider_type": "ntfy"}]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_key_without_the_scope_is_refused(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=False)
+        resp = await async_client.post(URL, headers={"X-API-Key": key}, json={"title": "t", "message": "m"})
+        assert resp.status_code == 403
+        assert "send_notifications" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_key_cant_do_what_its_owner_may_not(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        db_session.add(User(username="nobody", password_hash="x", role="user", is_active=True))
+        await db_session.commit()
+        key = await _key(db_session, owner_id=await _user_id(db_session, "nobody"), allowed=True)
+        resp = await async_client.post(URL, headers={"X-API-Key": key}, json={"title": "t", "message": "m"})
+        assert resp.status_code == 403
+        assert "notifications:update" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_admin_user_may_send(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        with _sent():
+            resp = await async_client.post(
+                URL, headers={"Authorization": f"Bearer {token}"}, json={"title": "Test", "message": "Hello"}
+            )
+        assert resp.status_code == 200
+        assert resp.json() == {"channels": 0}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unauthenticated_is_refused(self, async_client: AsyncClient):
+        await _admin_token(async_client)
+        resp = await async_client.post(URL, json={"title": "t", "message": "m"})
+        assert resp.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "payload",
+        [
+            {"title": "", "message": "m"},
+            {"title": "t", "message": "   "},
+            {"title": "t" * 121, "message": "m"},
+            {"title": "t", "message": "m", "url": "javascript:alert(1)"},
+            {"title": "t", "message": "m", "url": "http://a b"},
+        ],
+    )
+    async def test_refuses_what_isnt_plain_text_or_a_web_link(self, async_client: AsyncClient, db_session, payload):
+        await _admin_token(async_client)
+        key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=True)
+        resp = await async_client.post(URL, headers={"X-API-Key": key}, json=payload)
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_characters_are_dropped(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        await _channels(db_session)
+        key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=True)
+        with _sent() as send:
+            await async_client.post(URL, headers={"X-API-Key": key}, json={"title": "Hi\x07", "message": "a\nb\x1b"})
+        assert send.await_args_list[0].args[1:3] == ("Hi", "a\nb")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_each_caller_is_rate_limited(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        owner = await _user_id(db_session, "notifyadmin")
+        key = await _key(db_session, owner_id=owner, allowed=True)
+        other = await _key(db_session, owner_id=owner, allowed=True, name="Other app")
+        with _sent():
+            codes = [
+                (
+                    await async_client.post(URL, headers={"X-API-Key": key}, json={"title": "t", "message": "m"})
+                ).status_code
+                for _ in range(notification_routes.APP_MESSAGE_LIMIT + 1)
+            ]
+            other_code = (
+                await async_client.post(URL, headers={"X-API-Key": other}, json={"title": "t", "message": "m"})
+            ).status_code
+        assert codes[:-1] == [200] * notification_routes.APP_MESSAGE_LIMIT
+        assert codes[-1] == 429
+        assert other_code == 200
+
+
+class TestProviderAndKeyFlags:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_channel_switch_round_trips(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/notifications/",
+            json={"name": "Phone", "provider_type": "ntfy", "config": {"server": "https://ntfy.sh", "topic": "x"}},
+        )
+        assert resp.status_code == 200, resp.text
+        provider = resp.json()
+        assert provider["on_app_message"] is False
+        resp = await async_client.patch(f"/api/v1/notifications/{provider['id']}", json={"on_app_message": True})
+        assert resp.json()["on_app_message"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_key_scope_round_trips_and_defaults_off(self, async_client: AsyncClient):
+        token = await _admin_token(async_client)
+        headers = {"Authorization": f"Bearer {token}"}
+        resp = await async_client.post("/api/v1/api-keys/", headers=headers, json={"name": "orders"})
+        assert resp.json()["can_send_notifications"] is False
+        resp = await async_client.patch(
+            f"/api/v1/api-keys/{resp.json()['id']}", headers=headers, json={"can_send_notifications": True}
+        )
+        assert resp.json()["can_send_notifications"] is True

+ 1 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -607,6 +607,7 @@ NOT_A_FETCH_TARGET = {
     ("SystemConfigRequest", "backend_url"),
     ("ExternalLinkCreate", "url"),  # sidebar link, rendered in the UI, never requested
     ("ExternalLinkUpdate", "url"),
+    ("AppMessage", "url"),  # an app's link, appended to the notification text; never requested
     ("FileUpdate", "external_url"),  # library file link (#3077), rendered in the UI, never fetched
     ("MaintenanceTypeCreate", "wiki_url"),  # documentation link surfaced in the UI/notifications
     ("MaintenanceTypeUpdate", "wiki_url"),

+ 5 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -141,6 +141,7 @@ function isAlwaysAllowedIdentical(value) {
 // German loanwords / cognates from English are extensive. Most short technical
 // UI labels are identical in DE. List below curates the legitimate ones.
 const DE_COGNATES = [
+  'Apps',  // notifications badge for messages from connected apps — same word
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the DE term too
   'Auto',  // calibrationMode_auto — German UI uses the loanword (matches BambuStudio DE)
   'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
@@ -179,6 +180,7 @@ const DE_COGNATES = [
 
 // French cognates — many UI labels overlap with English exactly.
 const FR_COGNATES = [
+  'Apps',  // notifications badge for messages from connected apps — same word
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
@@ -276,6 +278,7 @@ const JA_COGNATES = [
 
 // Portuguese (BR) cognates.
 const PT_BR_COGNATES = [
+  'Apps',  // notifications badge for messages from connected apps — same word
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the PT-BR term too
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
@@ -351,6 +354,7 @@ const KO_COGNATES = [
 
 // Spanish cognates — words/phrases that are genuinely identical in Spanish.
 const ES_COGNATES = [
+  'Apps',  // notifications badge for messages from connected apps — same word
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the ES term too
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
@@ -441,6 +445,7 @@ const UK_COGNATES = [
 // are used untranslated by Dutch slicer users. Each entry below was
 // checked individually against the Dutch translation in #2891.
 const NL_COGNATES = [
+  'Apps',  // notifications badge for messages from connected apps — same word
   '1 printer', '{{n}} printers',
   '1 week', '(25%, 50%, 75%)', 'Accent', 'AMS Filament Backup',
   '{{ams}} Slot {{slot}}', 'Auto', 'Auto Home', 'Bambu Cloud',

+ 24 - 0
frontend/src/__tests__/components/BatchOrdersView.test.tsx

@@ -290,3 +290,27 @@ describe('BatchOrdersView (#342)', () => {
     expect(screen.getByRole('button', { name: 'queue.cancelBatch' })).toBeInTheDocument();
   });
 });
+
+describe('BatchOrdersView linked to one batch (/queue?batch=<id>)', () => {
+  it('shows every status and highlights the linked batch', async () => {
+    let statusAsked: string | null = 'unset';
+    server.use(
+      http.get('/api/v1/queue/batches', ({ request }) => {
+        statusAsked = new URL(request.url).searchParams.get('status');
+        return HttpResponse.json([
+          batch({ id: 7, name: '#1001 PS-BLK', status: 'completed' }),
+          batch({ id: 8, name: '#1002 CS-4' }),
+        ]);
+      }),
+    );
+    const scroll = vi.fn();
+    Element.prototype.scrollIntoView = scroll;
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} focusBatchId={7} />);
+
+    await screen.findByText('#1001 PS-BLK');
+    expect(statusAsked).toBeNull(); // "all": a finished batch still shows
+    expect(document.getElementById('batch-7')).toHaveClass('ring-2');
+    expect(document.getElementById('batch-8')).not.toHaveClass('ring-2');
+    await waitFor(() => expect(scroll).toHaveBeenCalled());
+  });
+});

+ 6 - 0
frontend/src/api/client.ts

@@ -1294,6 +1294,7 @@ export interface APIKey {
   can_manage_projects: boolean;
   can_access_cloud: boolean;
   can_update_energy_cost: boolean;
+  can_send_notifications: boolean;
   printer_ids: number[] | null;
   enabled: boolean;
   last_used: string | null;
@@ -1313,6 +1314,7 @@ export interface APIKeyCreate {
   can_manage_projects?: boolean;
   can_access_cloud?: boolean;
   can_update_energy_cost?: boolean;
+  can_send_notifications?: boolean;
   printer_ids?: number[] | null;
   expires_at?: string | null;
 }
@@ -1333,6 +1335,7 @@ export interface APIKeyUpdate {
   can_manage_projects?: boolean;
   can_access_cloud?: boolean;
   can_update_energy_cost?: boolean;
+  can_send_notifications?: boolean;
   printer_ids?: number[] | null;
   enabled?: boolean;
   expires_at?: string | null;
@@ -3019,6 +3022,7 @@ export interface NotificationProvider {
   on_location_ha_sensor_alert: boolean;
   // First layer complete
   on_first_layer_complete: boolean;
+  on_app_message: boolean;
   // Inventory stock alerts
   on_stock_reorder_alert: boolean;
   on_stock_break_alert: boolean;
@@ -3085,6 +3089,7 @@ export interface NotificationProviderCreate {
   on_location_ha_sensor_alert?: boolean;
   // First layer complete
   on_first_layer_complete?: boolean;
+  on_app_message?: boolean;
   // Inventory stock alerts
   on_stock_reorder_alert?: boolean;
   on_stock_break_alert?: boolean;
@@ -3144,6 +3149,7 @@ export interface NotificationProviderUpdate {
   on_location_ha_sensor_alert?: boolean;
   // First layer complete
   on_first_layer_complete?: boolean;
+  on_app_message?: boolean;
   // Inventory stock alerts
   on_stock_reorder_alert?: boolean;
   on_stock_break_alert?: boolean;

+ 14 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -54,6 +54,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
     provider?.on_location_ha_sensor_alert ?? false
   );
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
+  const [onAppMessage, setOnAppMessage] = useState(provider?.on_app_message ?? false);
 
   // Provider-specific config (scalar fields only — event_priorities is split out
   // into its own state because it's an object, not a string).
@@ -213,6 +214,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_ha_sensor_alert: onHaSensorAlert,
       on_location_ha_sensor_alert: onLocationHaSensorAlert,
       on_first_layer_complete: onFirstLayerComplete,
+      on_app_message: onAppMessage,
     };
 
     if (isEditing) {
@@ -715,6 +717,18 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               </div>
             </div>
 
+            {/* Messages other applications send (POST /notifications/app-message) */}
+            <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
+              <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.connectedApps')}</p>
+              <div className="flex items-center justify-between">
+                <div>
+                  <span className="text-sm text-white">{t('notifications.appMessages')}</span>
+                  <span className="text-xs text-bambu-gray ml-1">{t('notifications.appMessagesDescription')}</span>
+                </div>
+                <Toggle checked={onAppMessage} onChange={setOnAppMessage} />
+              </div>
+            </div>
+
             {/* Per-event ntfy priority (#990) */}
             {providerType === 'ntfy' && (() => {
               const enabledEvents: Array<{ key: string; label: string }> = [];

+ 15 - 4
frontend/src/components/BatchOrdersView.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Package, Layers, PlayCircle, XCircle, AlertTriangle, Clock, Coins } from 'lucide-react';
 import { api } from '../api/client';
@@ -15,6 +15,8 @@ type StatusFilter = 'active' | 'completed' | 'cancelled' | 'all';
 interface BatchOrdersViewProps {
   hasPermission: (p: Permission) => boolean;
   t: (key: string, options?: Record<string, unknown>) => string;
+  /** A batch to scroll to and highlight (``/queue?batch=<id>``, e.g. from Bambuddy Orders). */
+  focusBatchId?: number | null;
 }
 
 /**
@@ -26,10 +28,11 @@ interface BatchOrdersViewProps {
  * against what has actually been produced — including the runs that failed and
  * are therefore still owed.
  */
-export function BatchOrdersView({ hasPermission, t }: BatchOrdersViewProps) {
+export function BatchOrdersView({ hasPermission, t, focusBatchId = null }: BatchOrdersViewProps) {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
+  // A linked batch may be finished or cancelled: show every status so it's there.
+  const [statusFilter, setStatusFilter] = useState<StatusFilter>(focusBatchId ? 'all' : 'active');
   const [cancelTarget, setCancelTarget] = useState<PrintBatch | null>(null);
 
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
@@ -40,6 +43,11 @@ export function BatchOrdersView({ hasPermission, t }: BatchOrdersViewProps) {
     queryFn: () => api.getBatches(statusFilter === 'all' ? undefined : statusFilter),
   });
 
+  const focusFound = focusBatchId != null && !!batches?.some((b) => b.id === focusBatchId);
+  useEffect(() => {
+    if (focusFound) document.getElementById(`batch-${focusBatchId}`)?.scrollIntoView({ block: 'center' });
+  }, [focusFound, focusBatchId]);
+
   const invalidate = () => {
     queryClient.invalidateQueries({ queryKey: ['batches'] });
     queryClient.invalidateQueries({ queryKey: ['queue'] });
@@ -111,6 +119,7 @@ export function BatchOrdersView({ hasPermission, t }: BatchOrdersViewProps) {
               canDispatch={canDispatch}
               canCancel={canCancel}
               isDispatching={dispatchMutation.isPending && dispatchMutation.variables?.id === batch.id}
+              highlighted={batch.id === focusBatchId}
               onDispatch={(plateId) => dispatchMutation.mutate({ id: batch.id, plateId })}
               onCancel={() => setCancelTarget(batch)}
               t={t}
@@ -148,6 +157,7 @@ function BatchOrderCard({
   onDispatch,
   onCancel,
   t,
+  highlighted = false,
 }: {
   batch: PrintBatch;
   currency: string;
@@ -157,6 +167,7 @@ function BatchOrderCard({
   onDispatch: (plateId?: number | null) => void;
   onCancel: () => void;
   t: (key: string, options?: Record<string, unknown>) => string;
+  highlighted?: boolean;
 }) {
   // Progress is measured against the target, not against what was queued —
   // that is the whole difference between an order and a grouping.
@@ -170,7 +181,7 @@ function BatchOrderCard({
   const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now();
 
   return (
-    <Card className="p-4">
+    <Card id={`batch-${batch.id}`} className={`p-4 ${highlighted ? 'ring-2 ring-bambu-green' : ''}`}>
       <div className="flex flex-wrap items-start gap-3 mb-3">
         <div className="min-w-0 flex-1">
           <div className="flex items-center gap-2 flex-wrap">

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -183,6 +183,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_first_layer_complete && (
               <span className="px-2 py-0.5 bg-emerald-100 dark:bg-emerald-600/20 text-emerald-700 dark:text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
             )}
+            {provider.on_app_message && (
+              <span className="px-2 py-0.5 bg-sky-100 dark:bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs rounded">{t('notifications.appMessagesBadge')}</span>
+            )}
             {provider.on_print_missing_spool_assignment && (
               <span className="px-2 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs rounded">{t('notifications.missingSpoolAssignmentLabel')}</span>
             )}
@@ -645,6 +648,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                     onChange={(checked) => updateMutation.mutate({ on_queue_completed: checked })}
                   />
                 </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.appMessages')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.appMessagesDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_app_message ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_app_message: checked })}
+                  />
+                </div>
               </div>
 
               {/* Quiet Hours */}

+ 7 - 0
frontend/src/i18n/locales/de.ts

@@ -2276,6 +2276,9 @@ export default {
     updateEnergyCost: 'Strompreis aktualisieren',
     updateEnergyCostDescription: 'Erlaubt diesem Schlüssel, einen neuen Strompreis pro kWh an /settings/electricity-price zu senden. Nützlich für Home-Assistant-Automatisierungen mit dynamischen Tarifen (Tibber, Octopus usw.). Dies ist das einzige Einstellungsfeld, das per API-Schlüssel schreibbar ist.',
     energyCostBadge: 'Energie',
+    sendNotifications: 'Benachrichtigungen senden',
+    sendNotificationsDescription: 'Erlaubt diesem Schlüssel, Nachrichten über die Benachrichtigungskanäle zu senden, die Nachrichten verbundener Apps annehmen (POST /notifications/app-message). Für Apps wie Bambuddy Orders. Sonst nichts: Der Schlüssel kann die Kanäle weder lesen noch ändern.',
+    sendNotificationsBadge: 'Benachr.',
     legacyKey: 'Alt',
     legacyKeyTooltip: 'Wurde vor der nutzerbezogenen Eigentümerschaft erstellt; neu erstellen, um Cloud-Zugriff zu nutzen',
     unnamedKey: 'Unbenannter Schlüssel',
@@ -6249,6 +6252,10 @@ export default {
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
+    connectedApps: 'Verbundene Apps',
+    appMessages: 'Nachrichten verbundener Apps',
+    appMessagesDescription: 'Nachrichten zustellen, die andere Anwendungen über Bambuddy senden, z. B. Bambuddy Orders',
+    appMessagesBadge: 'Apps',
     missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
     billingChargeFailedLabel: 'Abrechnungsfehler',
     billingChargeFailedDescription: 'Benachrichtigen, wenn Druckkosten nicht verbucht werden konnten',

+ 7 - 0
frontend/src/i18n/locales/en.ts

@@ -2296,6 +2296,9 @@ export default {
     updateEnergyCost: 'Update electricity price',
     updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
     energyCostBadge: 'Energy',
+    sendNotifications: 'Send notifications',
+    sendNotificationsDescription: 'Allow this key to send messages through the notification channels that accept messages from connected apps (POST /notifications/app-message). For apps like Bambuddy Orders. Nothing else: the key can\'t read or change the channels.',
+    sendNotificationsBadge: 'Notify',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Unnamed Key',
@@ -6300,6 +6303,10 @@ export default {
     bedCooledDescription: 'Bed cooled below threshold after print',
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
+    connectedApps: 'Connected apps',
+    appMessages: 'Messages from connected apps',
+    appMessagesDescription: 'Deliver messages other applications send through Bambuddy, e.g. Bambuddy Orders',
+    appMessagesBadge: 'Apps',
     missingSpoolAssignmentLabel: 'Missing Spool Assignment',
     billingChargeFailedLabel: 'Billing Charge Failed',
     billingChargeFailedDescription: 'Notify when print costs could not be recorded',

+ 7 - 0
frontend/src/i18n/locales/es.ts

@@ -2279,6 +2279,9 @@ export default {
     updateEnergyCost: 'Actualizar el precio de la electricidad',
     updateEnergyCostDescription: 'Permite que esta clave envíe por POST un nuevo precio de electricidad por kWh a /settings/electricity-price. Útil para las automatizaciones de tarifa dinámica de Home Assistant (Tibber, Octopus, etc.). Este es el único campo de ajustes que se puede escribir mediante una clave API.',
     energyCostBadge: 'Energía',
+    sendNotifications: 'Enviar notificaciones',
+    sendNotificationsDescription: 'Permite a esta clave enviar mensajes a través de los canales de notificación que aceptan mensajes de aplicaciones conectadas (POST /notifications/app-message). Para aplicaciones como Bambuddy Orders. Nada más: la clave no puede leer ni cambiar los canales.',
+    sendNotificationsBadge: 'Notif.',
     legacyKey: 'Heredada',
     legacyKeyTooltip: 'Creada antes de la propiedad por usuario; vuelva a crearla para usar el acceso a la nube',
     unnamedKey: 'Clave sin nombre',
@@ -6257,6 +6260,10 @@ export default {
     bedCooledDescription: 'La cama se enfrió por debajo del umbral tras la impresión',
     firstLayerCompleteLabel: 'Primera capa completada',
     firstLayerCompleteDescription: 'Notificar con una captura cuando termina la primera capa',
+    connectedApps: 'Aplicaciones conectadas',
+    appMessages: 'Mensajes de aplicaciones conectadas',
+    appMessagesDescription: 'Entregar los mensajes que otras aplicaciones envían a través de Bambuddy, p. ej. Bambuddy Orders',
+    appMessagesBadge: 'Apps',
     missingSpoolAssignmentLabel: 'Falta la asignación de bobina',
     billingChargeFailedLabel: 'Error de facturación',
     billingChargeFailedDescription: 'Notificar cuando no se puedan registrar los costes de impresión',

+ 7 - 0
frontend/src/i18n/locales/fr.ts

@@ -2232,6 +2232,9 @@ export default {
     updateEnergyCost: 'Mettre à jour le tarif électrique',
     updateEnergyCostDescription: 'Autoriser cette clé à POSTer un nouveau tarif électrique par kWh sur /settings/electricity-price. Utile pour les automatisations Home Assistant à tarif dynamique (Tibber, Octopus, etc.). Il s\'agit du seul champ de paramètres modifiable via clé API.',
     energyCostBadge: 'Énergie',
+    sendNotifications: 'Envoyer des notifications',
+    sendNotificationsDescription: 'Autorise cette clé à envoyer des messages via les canaux de notification qui acceptent les messages des applications connectées (POST /notifications/app-message). Pour des applications comme Bambuddy Orders. Rien d\'autre : la clé ne peut ni lire ni modifier les canaux.',
+    sendNotificationsBadge: 'Notif.',
     legacyKey: 'Hérité',
     legacyKeyTooltip: 'Créé avant la propriété par utilisateur ; recréez pour l\'accès cloud',
     unnamedKey: 'Clé sans nom',
@@ -6239,6 +6242,10 @@ export default {
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
+    connectedApps: 'Applications connectées',
+    appMessages: 'Messages des applications connectées',
+    appMessagesDescription: 'Transmettre les messages que d\'autres applications envoient via Bambuddy, p. ex. Bambuddy Orders',
+    appMessagesBadge: 'Apps',
     missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
     billingChargeFailedLabel: 'Échec de facturation',
     billingChargeFailedDescription: "Notifier lorsque les coûts d'impression ne peuvent pas être enregistrés",

+ 7 - 0
frontend/src/i18n/locales/it.ts

@@ -2232,6 +2232,9 @@ export default {
     updateEnergyCost: 'Aggiorna prezzo elettricità',
     updateEnergyCostDescription: 'Consenti a questa chiave di inviare in POST un nuovo prezzo elettricità per kWh a /settings/electricity-price. Utile per automazioni Home Assistant a tariffa dinamica (Tibber, Octopus, ecc.). Questo è l\'unico campo di impostazioni scrivibile via API key.',
     energyCostBadge: 'Energia',
+    sendNotifications: 'Invia notifiche',
+    sendNotificationsDescription: 'Consente a questa chiave di inviare messaggi tramite i canali di notifica che accettano messaggi dalle app collegate (POST /notifications/app-message). Per app come Bambuddy Orders. Nient\'altro: la chiave non può leggere né modificare i canali.',
+    sendNotificationsBadge: 'Notif.',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Creato prima della proprietà per utente; ricreare per accesso cloud',
     unnamedKey: 'Chiave senza nome',
@@ -6238,6 +6241,10 @@ export default {
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
+    connectedApps: 'App collegate',
+    appMessages: 'Messaggi dalle app collegate',
+    appMessagesDescription: 'Recapita i messaggi che altre applicazioni inviano tramite Bambuddy, ad es. Bambuddy Orders',
+    appMessagesBadge: 'App',
     missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
     billingChargeFailedLabel: 'Errore di addebito',
     billingChargeFailedDescription: 'Notifica quando non è possibile registrare i costi di stampa',

+ 7 - 0
frontend/src/i18n/locales/ja.ts

@@ -2275,6 +2275,9 @@ export default {
     updateEnergyCost: '電気料金を更新',
     updateEnergyCostDescription: 'このキーが /settings/electricity-price に新しいkWhごとの電気料金をPOSTすることを許可。Home Assistantの動的料金自動化(Tibber、Octopusなど)に便利。これはAPIキーで書き込み可能な唯一の設定フィールドです。',
     energyCostBadge: '電力',
+    sendNotifications: '通知の送信',
+    sendNotificationsDescription: 'このキーで、連携アプリからのメッセージを受け付ける通知チャネル経由でメッセージを送信できるようにします(POST /notifications/app-message)。Bambuddy Orders などのアプリ向けです。それ以外の権限はなく、チャネルの読み取りや変更はできません。',
+    sendNotificationsBadge: '通知',
     legacyKey: 'レガシー',
     legacyKeyTooltip: 'ユーザー所有以前に作成 - クラウドアクセスには再作成が必要',
     unnamedKey: '名前なしキー',
@@ -6250,6 +6253,10 @@ export default {
     bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
+    connectedApps: '連携アプリ',
+    appMessages: '連携アプリからのメッセージ',
+    appMessagesDescription: '他のアプリケーションが Bambuddy 経由で送信するメッセージを配信します(例: Bambuddy Orders)',
+    appMessagesBadge: 'アプリ',
     missingSpoolAssignmentLabel: 'スプール割り当て不足',
     billingChargeFailedLabel: '請求処理エラー',
     billingChargeFailedDescription: '印刷コストを記録できなかった場合に通知します',

+ 7 - 0
frontend/src/i18n/locales/ko.ts

@@ -2799,6 +2799,9 @@ export default {
     updateEnergyCost: '전기 요금 업데이트',
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     energyCostBadge: '에너지',
+    sendNotifications: '알림 보내기',
+    sendNotificationsDescription: '이 키가 연결된 앱의 메시지를 받는 알림 채널을 통해 메시지를 보낼 수 있도록 허용합니다(POST /notifications/app-message). Bambuddy Orders 같은 앱용입니다. 그 외에는 없으며, 키로 채널을 읽거나 변경할 수 없습니다.',
+    sendNotificationsBadge: '알림',
     passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함',
 
     pipelineLimits: {
@@ -5967,6 +5970,10 @@ export default {
     bedCooledDescription: '인쇄 후 베드가 임계값 이하로 냉각됨',
     firstLayerCompleteLabel: '첫 번째 레이어 완료',
     firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림',
+    connectedApps: '연결된 앱',
+    appMessages: '연결된 앱의 메시지',
+    appMessagesDescription: '다른 애플리케이션이 Bambuddy를 통해 보내는 메시지를 전달합니다(예: Bambuddy Orders)',
+    appMessagesBadge: '앱',
     missingSpoolAssignmentLabel: '스풀 할당 누락',
     billingChargeFailedLabel: '결제 처리 실패',
     billingChargeFailedDescription: '인쇄 비용을 기록하지 못한 경우 알림',

+ 7 - 0
frontend/src/i18n/locales/nl.ts

@@ -2296,6 +2296,9 @@ export default {
     updateEnergyCost: 'Elektriciteitsprijs bijwerken',
     updateEnergyCostDescription: 'Sta toe dat deze sleutel via POST een nieuwe elektriciteitsprijs per kWh naar /settings/electricity-price stuurt. Handig voor Home Assistant-automatiseringen met dynamische tarieven (Tibber, Octopus, enz.). Dit is het enige instellingenveld dat via een API-sleutel schrijfbaar is.',
     energyCostBadge: 'Energie',
+    sendNotifications: 'Meldingen versturen',
+    sendNotificationsDescription: 'Staat deze sleutel toe berichten te versturen via de meldingskanalen die berichten van gekoppelde apps accepteren (POST /notifications/app-message). Voor apps zoals Bambuddy Orders. Verder niets: de sleutel kan de kanalen niet lezen of wijzigen.',
+    sendNotificationsBadge: 'Meldingen',
     legacyKey: 'Verouderd',
     legacyKeyTooltip: 'Aangemaakt vóór eigendom per gebruiker; maak opnieuw om cloudtoegang te gebruiken',
     unnamedKey: 'Naamloze sleutel',
@@ -6300,6 +6303,10 @@ export default {
     bedCooledDescription: 'Bed na afdruk afgekoeld tot onder drempel',
     firstLayerCompleteLabel: 'Eerste laag voltooid',
     firstLayerCompleteDescription: 'Melden met momentopname wanneer de eerste laag is voltooid',
+    connectedApps: 'Gekoppelde apps',
+    appMessages: 'Berichten van gekoppelde apps',
+    appMessagesDescription: 'Berichten bezorgen die andere toepassingen via Bambuddy versturen, bijv. Bambuddy Orders',
+    appMessagesBadge: 'Apps',
     missingSpoolAssignmentLabel: 'Ontbrekende spoeltoewijzing',
     billingChargeFailedLabel: 'Kostenregistratie mislukt',
     billingChargeFailedDescription: 'Melden wanneer afdrukkosten niet konden worden geregistreerd',

+ 7 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2232,6 +2232,9 @@ export default {
     updateEnergyCost: 'Atualizar preço da eletricidade',
     updateEnergyCostDescription: 'Permitir que esta chave envie POST de um novo preço por kWh para /settings/electricity-price. Útil para automações de tarifa dinâmica do Home Assistant (Tibber, Octopus, etc.). Este é o único campo de configuração gravável via API key.',
     energyCostBadge: 'Energia',
+    sendNotifications: 'Enviar notificações',
+    sendNotificationsDescription: 'Permite que esta chave envie mensagens pelos canais de notificação que aceitam mensagens de aplicativos conectados (POST /notifications/app-message). Para aplicativos como o Bambuddy Orders. Nada além disso: a chave não pode ler nem alterar os canais.',
+    sendNotificationsBadge: 'Notif.',
     legacyKey: 'Legado',
     legacyKeyTooltip: 'Criado antes da propriedade por usuário; recrie para acesso à nuvem',
     unnamedKey: 'Chave Sem Nome',
@@ -6238,6 +6241,10 @@ export default {
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
+    connectedApps: 'Aplicativos conectados',
+    appMessages: 'Mensagens de aplicativos conectados',
+    appMessagesDescription: 'Entregar mensagens que outros aplicativos enviam pelo Bambuddy, p. ex. Bambuddy Orders',
+    appMessagesBadge: 'Apps',
     missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
     billingChargeFailedLabel: 'Falha na cobrança',
     billingChargeFailedDescription: 'Notificar quando os custos de impressão não puderem ser registrados',

+ 7 - 0
frontend/src/i18n/locales/ru.ts

@@ -2157,6 +2157,9 @@ export default {
     updateEnergyCost: "Обновление тарифа на электроэнергию",
     updateEnergyCostDescription: "Разрешить этому ключу отправлять новую цену за кВт⋅ч методом POST в /settings/electricity-price. Полезно для автоматизаций динамического тарифа Home Assistant (Tibber, Octopus и т. п.). Это единственный параметр настроек, доступный для записи через ключ API.",
     energyCostBadge: "Энергия",
+    sendNotifications: 'Отправка уведомлений',
+    sendNotificationsDescription: 'Разрешает этому ключу отправлять сообщения через каналы уведомлений, принимающие сообщения подключённых приложений (POST /notifications/app-message). Для приложений вроде Bambuddy Orders. Больше ничего: ключ не может читать или изменять каналы.',
+    sendNotificationsBadge: 'Уведомл.',
     legacyKey: "Устаревший",
     legacyKeyTooltip: "Создан до появления привязки к пользователям; пересоздайте для доступа к облаку",
     unnamedKey: "Ключ без названия",
@@ -5954,6 +5957,10 @@ export default {
     bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",
     firstLayerCompleteLabel: "Первый слой завершён",
     firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
+    connectedApps: 'Подключённые приложения',
+    appMessages: 'Сообщения подключённых приложений',
+    appMessagesDescription: 'Доставлять сообщения, которые другие приложения отправляют через Bambuddy, например Bambuddy Orders',
+    appMessagesBadge: 'Приложения',
     missingSpoolAssignmentLabel: "Катушка не назначена",
     billingChargeFailedLabel: 'Ошибка списания',
     billingChargeFailedDescription: 'Уведомлять, если не удалось учесть стоимость печати',

+ 7 - 0
frontend/src/i18n/locales/sv.ts

@@ -2296,6 +2296,9 @@ export default {
     updateEnergyCost: 'Uppdatera elpris',
     updateEnergyCostDescription: 'Tillåt denna nyckel att POST:a ett nytt pris per kWh till /settings/electricity-price. Användbart för Home Assistants dynamiska tariffautomatiseringar (Tibber, Octopus, etc.). Detta är det enda inställningsfält som kan skrivas via API-nyckel.',
     energyCostBadge: 'Energi',
+    sendNotifications: 'Skicka aviseringar',
+    sendNotificationsDescription: 'Tillåter nyckeln att skicka meddelanden via de aviseringskanaler som tar emot meddelanden från anslutna appar (POST /notifications/app-message). För appar som Bambuddy Orders. Inget annat: nyckeln kan inte läsa eller ändra kanalerna.',
+    sendNotificationsBadge: 'Avis.',
     legacyKey: 'Äldre nyckel',
     legacyKeyTooltip: 'Skapad före användarägande; skapa igen för att använda molnåtkomst',
     unnamedKey: 'Namnlös nyckel',
@@ -6300,6 +6303,10 @@ errors: {
     bedCooledDescription: 'Byggplattan kyld under tröskelvärdet efter utskrift',
     firstLayerCompleteLabel: 'Första skikt klart',
     firstLayerCompleteDescription: 'Notis med ögonblicksbild när första skiktet är klart',
+    connectedApps: 'Anslutna appar',
+    appMessages: 'Meddelanden från anslutna appar',
+    appMessagesDescription: 'Leverera meddelanden som andra program skickar via Bambuddy, t.ex. Bambuddy Orders',
+    appMessagesBadge: 'Appar',
     missingSpoolAssignmentLabel: 'Rulltilldelning saknas',
     billingChargeFailedLabel: 'Faktureringsavgift misslyckades',
     billingChargeFailedDescription: 'Notis när utskriftskostnader inte kunde registreras',

+ 7 - 0
frontend/src/i18n/locales/tr.ts

@@ -2280,6 +2280,9 @@ export default {
     updateEnergyCost: 'Elektrik fiyatını güncelle',
     updateEnergyCostDescription: "Bu anahtarın /settings/electricity-price üzerine yeni bir kWh başına elektrik fiyatı POST etmesine izin ver. Home Assistant dinamik tarife otomasyonları (Tibber, Octopus, vb.) için kullanışlıdır. Bu, API anahtarıyla yazılabilen tek ayar alanıdır.",
     energyCostBadge: 'Enerji',
+    sendNotifications: 'Bildirim gönder',
+    sendNotificationsDescription: 'Bu anahtarın, bağlı uygulamalardan gelen mesajları kabul eden bildirim kanalları üzerinden mesaj göndermesine izin verir (POST /notifications/app-message). Bambuddy Orders gibi uygulamalar için. Başka bir şey değil: anahtar kanalları okuyamaz veya değiştiremez.',
+    sendNotificationsBadge: 'Bildirim',
     legacyKey: 'Eski',
     legacyKeyTooltip: 'Kullanıcı başına sahiplikten önce oluşturuldu; bulut erişimini kullanmak için yeniden oluşturun',
     unnamedKey: 'Adsız Anahtar',
@@ -6205,6 +6208,10 @@ export default {
     bedCooledDescription: 'Baskıdan sonra tabla eşiğin altına soğudu',
     firstLayerCompleteLabel: 'İlk Katman Tamamlandı',
     firstLayerCompleteDescription: 'İlk katman bittiğinde anlık görüntüyle bildir',
+    connectedApps: 'Bağlı uygulamalar',
+    appMessages: 'Bağlı uygulamalardan gelen mesajlar',
+    appMessagesDescription: 'Diğer uygulamaların Bambuddy üzerinden gönderdiği mesajları iletir, ör. Bambuddy Orders',
+    appMessagesBadge: 'Uygulamalar',
     missingSpoolAssignmentLabel: 'Eksik Makara Ataması',
     billingChargeFailedLabel: 'Ücretlendirme hatası',
     billingChargeFailedDescription: 'Baskı maliyetleri kaydedilemediğinde bildirim gönder',

+ 7 - 0
frontend/src/i18n/locales/uk.ts

@@ -2295,6 +2295,9 @@ export default {
     updateEnergyCost: "Оновлювати ціну електроенергії",
     updateEnergyCostDescription: "Дозволити цьому ключу надсилати нову ціну електроенергії за кВт·год до /settings/electricity-price. Це корисно для автоматизацій Home Assistant із динамічними тарифами (Tibber, Octopus тощо). Це єдине поле налаштувань, яке можна змінювати за допомогою ключа API.",
     energyCostBadge: "Енергія",
+    sendNotifications: 'Надсилання сповіщень',
+    sendNotificationsDescription: 'Дозволяє цьому ключу надсилати повідомлення через канали сповіщень, які приймають повідомлення підключених застосунків (POST /notifications/app-message). Для застосунків на кшталт Bambuddy Orders. Більше нічого: ключ не може читати чи змінювати канали.',
+    sendNotificationsBadge: 'Сповіщ.',
     legacyKey: "Застарілий",
     legacyKeyTooltip: "Створено до отримання права власності на користувача; повторно створити, щоб використовувати доступ до хмари",
     unnamedKey: "Безіменний ключ",
@@ -6292,6 +6295,10 @@ export default {
     bedCooledDescription: "Після друку стіл охолов нижче заданого порога",
     firstLayerCompleteLabel: "Перший шар завершено",
     firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
+    connectedApps: 'Підключені застосунки',
+    appMessages: 'Повідомлення підключених застосунків',
+    appMessagesDescription: 'Доставляти повідомлення, які інші застосунки надсилають через Bambuddy, наприклад Bambuddy Orders',
+    appMessagesBadge: 'Застосунки',
     missingSpoolAssignmentLabel: "Відсутнє призначення котушки",
     billingChargeFailedLabel: 'Помилка списання',
     billingChargeFailedDescription: 'Сповіщати, якщо не вдалося облікувати вартість друку',

+ 7 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2277,6 +2277,9 @@ export default {
     updateEnergyCost: '更新电价',
     updateEnergyCostDescription: '允许此密钥向 /settings/electricity-price POST 新的每千瓦时电价。适用于 Home Assistant 动态电价自动化(Tibber、Octopus 等)。这是唯一可通过 API 密钥写入的设置字段。',
     energyCostBadge: '能耗',
+    sendNotifications: '发送通知',
+    sendNotificationsDescription: '允许此密钥通过接受已连接应用消息的通知渠道发送消息(POST /notifications/app-message)。适用于 Bambuddy Orders 等应用。仅此而已:该密钥无法读取或更改渠道。',
+    sendNotificationsBadge: '通知',
     legacyKey: '传统',
     legacyKeyTooltip: '在按用户所有权之前创建;需重建以使用云端访问',
     unnamedKey: '未命名密钥',
@@ -6237,6 +6240,10 @@ export default {
     bedCooledDescription: '打印后热床温度降至阈值以下',
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
+    connectedApps: '已连接的应用',
+    appMessages: '来自已连接应用的消息',
+    appMessagesDescription: '投递其他应用通过 Bambuddy 发送的消息,例如 Bambuddy Orders',
+    appMessagesBadge: '应用',
     missingSpoolAssignmentLabel: '缺少料卷分配',
     billingChargeFailedLabel: '计费失败',
     billingChargeFailedDescription: '无法记录打印费用时通知',

+ 7 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2277,6 +2277,9 @@ export default {
     updateEnergyCost: '更新電價',
     updateEnergyCostDescription: '允許此金鑰向 /settings/electricity-price POST 新的每千瓦時電價。適用於 Home Assistant 動態電價自動化(Tibber、Octopus 等)。這是唯一可透過 API 金鑰寫入的設定欄位。',
     energyCostBadge: '能耗',
+    sendNotifications: '傳送通知',
+    sendNotificationsDescription: '允許此金鑰透過接受已連線應用程式訊息的通知管道傳送訊息(POST /notifications/app-message)。適用於 Bambuddy Orders 等應用程式。僅此而已:此金鑰無法讀取或變更管道。',
+    sendNotificationsBadge: '通知',
     legacyKey: '舊版',
     legacyKeyTooltip: '在按使用者所有權之前建立;需重建以使用雲端存取',
     unnamedKey: '未命名金鑰',
@@ -6237,6 +6240,10 @@ export default {
     bedCooledDescription: '列印後熱床溫度降至閾值以下',
     firstLayerCompleteLabel: '首層列印完成',
     firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
+    connectedApps: '已連線的應用程式',
+    appMessages: '來自已連線應用程式的訊息',
+    appMessagesDescription: '傳遞其他應用程式透過 Bambuddy 傳送的訊息,例如 Bambuddy Orders',
+    appMessagesBadge: '應用程式',
     missingSpoolAssignmentLabel: '缺少料卷分配',
     billingChargeFailedLabel: '計費失敗',
     billingChargeFailedDescription: '無法記錄列印費用時通知',

+ 8 - 1
frontend/src/pages/QueuePage.tsx

@@ -1476,11 +1476,18 @@ export function QueuePage() {
   // History tab renders unconditionally so this no longer drives the UI.
   // Tabbed page structure: Active queue stays as the main view; History
   // and Timeline split off. Persists per-user via localStorage.
+  // /queue?batch=<id>: a link to one batch (e.g. from Bambuddy Orders), read once.
+  const [focusBatchId] = useState<number | null>(() => {
+    const id = Number(new URLSearchParams(window.location.search).get('batch'));
+    return Number.isInteger(id) && id > 0 ? id : null;
+  });
   const [activeTab, setActiveTab] = useState<'queue' | 'batches' | 'history' | 'timeline' | 'pipelines'>(() => {
     // URL deep-link wins so the legacy /pipelines/runs redirect lands on the
     // right tab. localStorage holds the per-user last-selected fallback.
     const search = new URLSearchParams(window.location.search);
     const url = search.get('tab');
+    // A link to one batch (/queue?batch=<id>) opens the tab that shows it.
+    if (search.get('batch')) return 'batches';
     if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue' || url === 'batches') {
       return url;
     }
@@ -2545,7 +2552,7 @@ export function QueuePage() {
       {activeTab === 'pipelines' ? (
         <PipelineRunsView />
       ) : activeTab === 'batches' ? (
-        <BatchOrdersView hasPermission={hasPermission} t={t} />
+        <BatchOrdersView hasPermission={hasPermission} t={t} focusBatchId={focusBatchId} />
       ) : isLoading ? (
         <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
       ) : queue?.length === 0 ? (

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

@@ -299,6 +299,7 @@ export function SettingsPage() {
     can_manage_projects: true,
     can_access_cloud: false,
     can_update_energy_cost: false,
+    can_send_notifications: false,
   });
   const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
   const [showApiKeyQR, setShowApiKeyQR] = useState(false);
@@ -4634,6 +4635,18 @@ export function SettingsPage() {
                           <p className="text-xs text-bambu-gray">{t('settings.updateEnergyCostDescription')}</p>
                         </div>
                       </label>
+                      <label className="flex items-center gap-3 cursor-pointer">
+                        <input
+                          type="checkbox"
+                          checked={newAPIKeyPermissions.can_send_notifications}
+                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_send_notifications: e.target.checked }))}
+                          className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                        />
+                        <div>
+                          <span className="text-white">{t('settings.sendNotifications')}</span>
+                          <p className="text-xs text-bambu-gray">{t('settings.sendNotificationsDescription')}</p>
+                        </div>
+                      </label>
                     </div>
                   </div>
                   <div className="flex items-center gap-2 pt-2">
@@ -4712,6 +4725,9 @@ export function SettingsPage() {
                             {key.can_update_energy_cost && (
                               <span className="px-1.5 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 rounded">{t('settings.energyCostBadge')}</span>
                             )}
+                            {key.can_send_notifications && (
+                              <span className="px-1.5 py-0.5 bg-sky-100 dark:bg-sky-500/20 text-sky-700 dark:text-sky-400 rounded">{t('settings.sendNotificationsBadge')}</span>
+                            )}
                             {key.user_id === null && (
                               <span
                                 className="px-1.5 py-0.5 bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 rounded"

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 1
static/assets/PdfPreviewModal-HUSkHM8p.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DIlPhqnM.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 1
static/assets/index-C4HAeZyC.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/pdf-Cz0mxoII.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DwLYwYOk.js"></script>
+    <script type="module" crossorigin src="/assets/index-C4HAeZyC.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-DDT9pHR2.css">
   </head>

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov