mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
Show announcements from the Bambuddy maintainers
Fetch a signed feed.json from the public bambuddy-notifications repo on
GitHub at startup and every 6 hours. Nothing about the install is sent;
targeting (version, beta channel, install type) is decided locally.
- Ed25519 against a key built into the app; an older serial is refused so a
withdrawn message can't come back. The feed replaces the stored list, and
a failed or rejected fetch keeps the last good one.
- Sidebar entry above System with an unread count, a slide-over list, and a
banner for unread important/critical messages. Read state per user.
- Admins by default; Settings > General > Updates can show them to all
users or switch them off, which also stops the fetch.
- Plain text only; links to github.com and bambuddy.cool only.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"""Announcements from the Bambuddy maintainers (services/announcements.py).
|
||||
|
||||
Shown to administrators, and to every signed-in user when the
|
||||
``announcements_all_users`` setting is on. With authentication off whoever opens
|
||||
Bambuddy runs it, so they see them. Anyone else gets an empty list rather than a
|
||||
403: the sidebar entry and the banner are hidden on "nothing to show", which is
|
||||
the same answer for "nothing published" and "not for you".
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import is_auth_enabled, require_auth_if_enabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services import announcements as service
|
||||
|
||||
router = APIRouter(prefix="/announcements", tags=["announcements"])
|
||||
|
||||
|
||||
async def _may_see(db: AsyncSession, user: User | None) -> bool:
|
||||
if not await service.is_enabled(db):
|
||||
return False
|
||||
if not await is_auth_enabled(db):
|
||||
return True
|
||||
# Authenticated by API key: a script, not a person with an inbox.
|
||||
if user is None:
|
||||
return False
|
||||
if user.is_admin:
|
||||
return True
|
||||
all_users = (
|
||||
await db.execute(select(Settings.value).where(Settings.key == service.ALL_USERS_KEY))
|
||||
).scalar_one_or_none()
|
||||
return (all_users or "").lower() == "true"
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_announcements(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
) -> list[dict]:
|
||||
"""Live announcements for this user, newest first, with their read state."""
|
||||
if not await _may_see(db, current_user):
|
||||
return []
|
||||
return await service.list_for(db, current_user.id if current_user else None)
|
||||
|
||||
|
||||
@router.post("/{public_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def mark_announcement_read(
|
||||
public_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
) -> None:
|
||||
if not await _may_see(db, current_user):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Announcement not found")
|
||||
if not await service.mark_read(db, public_id, current_user.id if current_user else None):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Announcement not found")
|
||||
await db.commit()
|
||||
@@ -186,6 +186,8 @@ _BOOL_SETTING_KEYS = frozenset(
|
||||
"check_updates",
|
||||
"check_printer_firmware",
|
||||
"include_beta_updates",
|
||||
"announcements_enabled",
|
||||
"announcements_all_users",
|
||||
"virtual_printer_enabled",
|
||||
"ftp_retry_enabled",
|
||||
"mqtt_enabled",
|
||||
|
||||
@@ -288,6 +288,7 @@ async def init_db():
|
||||
active_print_spoolman,
|
||||
ams_history,
|
||||
ams_label,
|
||||
announcement,
|
||||
api_key,
|
||||
archive,
|
||||
auth_ephemeral,
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy import delete, or_, select, text
|
||||
|
||||
from backend.app.api.routes import (
|
||||
ams_history,
|
||||
announcements,
|
||||
api_keys,
|
||||
archive_purge,
|
||||
archives,
|
||||
@@ -9978,6 +9979,11 @@ async def lifespan(app: FastAPI):
|
||||
# L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
|
||||
start_auth_cleanup()
|
||||
|
||||
# Maintainer announcements: a signed feed fetched from GitHub every few hours.
|
||||
from backend.app.services import announcements as announcements_service
|
||||
|
||||
announcements_service.start()
|
||||
|
||||
from backend.app.services.printer_media import start_printer_download_cleanup
|
||||
|
||||
start_printer_download_cleanup()
|
||||
@@ -10032,6 +10038,9 @@ async def lifespan(app: FastAPI):
|
||||
logging.warning("Failed to shut down camera broadcasters: %s", e)
|
||||
stop_expected_prints_cleanup()
|
||||
stop_auth_cleanup()
|
||||
from backend.app.services import announcements as announcements_service
|
||||
|
||||
announcements_service.stop()
|
||||
from backend.app.services.printer_media import stop_printer_download_cleanup
|
||||
|
||||
await stop_printer_download_cleanup()
|
||||
@@ -10586,6 +10595,7 @@ app.include_router(spoolman.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(updates.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(announcements.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(camera.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(camwall.router, prefix=app_settings.api_prefix)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from backend.app.models.ams_history import AMSSensorHistory
|
||||
from backend.app.models.ams_label import AmsLabel
|
||||
from backend.app.models.announcement import Announcement, AnnouncementRead
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
|
||||
@@ -97,6 +98,8 @@ __all__ = [
|
||||
"ColorCatalogEntry",
|
||||
"SpoolBuddyDevice",
|
||||
"SponsorToastState",
|
||||
"Announcement",
|
||||
"AnnouncementRead",
|
||||
"UserEmailPreference",
|
||||
"UserOTPCode",
|
||||
"UserTOTP",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Announcements from the Bambuddy maintainers, and who has read them.
|
||||
|
||||
Fetched from a signed feed on GitHub (services/announcements.py). Only the ones
|
||||
that target this install are stored, and the feed is authoritative: a message
|
||||
withdrawn upstream is deleted here on the next fetch, together with its read
|
||||
markers.
|
||||
|
||||
``AnnouncementRead.user_id`` is nullable: with authentication off there are no
|
||||
users, and the install's read state is a single NULL-keyed row per message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class Announcement(Base):
|
||||
__tablename__ = "announcements"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# The feed's id for the message. Stable across edits upstream, so a corrected
|
||||
# typo does not come back as unread.
|
||||
public_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
level: Mapped[str] = mapped_column(String(16)) # info | important | critical
|
||||
# JSON {"en": {"title", "body", "link_label"?}, ...}. Text for SQLite/Postgres
|
||||
# uniformity; the service serialises with json.dumps.
|
||||
texts: Mapped[str] = mapped_column(Text)
|
||||
link_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
# Naive UTC, like every other timestamp in Bambuddy.
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class AnnouncementRead(Base):
|
||||
__tablename__ = "announcement_reads"
|
||||
__table_args__ = (UniqueConstraint("announcement_id", "user_id", name="uq_announcement_reads_user"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
announcement_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("announcements.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
read_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
@@ -96,6 +96,12 @@ class AppSettings(BaseModel):
|
||||
check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
|
||||
check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
|
||||
include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
|
||||
announcements_enabled: bool = Field(
|
||||
default=True, description="Fetch announcements from the Bambuddy maintainers (a signed file on GitHub)"
|
||||
)
|
||||
announcements_all_users: bool = Field(
|
||||
default=False, description="Show announcements to every signed-in user, not only administrators"
|
||||
)
|
||||
|
||||
# Language
|
||||
language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)")
|
||||
@@ -696,6 +702,8 @@ class AppSettingsUpdate(BaseModel):
|
||||
check_updates: bool | None = None
|
||||
check_printer_firmware: bool | None = None
|
||||
include_beta_updates: bool | None = None
|
||||
announcements_enabled: bool | None = None
|
||||
announcements_all_users: bool | None = None
|
||||
local_login_enabled: bool | None = None
|
||||
language: str | None = None
|
||||
notification_language: str | None = None
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Announcements from the Bambuddy maintainers: fetch, verify, keep what applies.
|
||||
|
||||
Bambuddy fetches one file, ``feed.json``, from the public
|
||||
``maziggy/bambuddy-notifications`` repo on raw.githubusercontent.com -- the host
|
||||
the update check already talks to. No Bambuddy server is contacted and nothing
|
||||
about this install is sent: whether a message applies here (version range,
|
||||
channel, install type) is decided below, locally.
|
||||
|
||||
The file is written by the maintainers' registrar and signed with Ed25519:
|
||||
|
||||
{"format": 1, "key_id": "...", "signature": "<base64>", "payload": {...}}
|
||||
|
||||
The signature covers ``canonical(payload)``. A file that does not verify against
|
||||
a key in ``TRUSTED_KEYS`` is ignored, so neither a copy of the repo nor anyone in
|
||||
the middle can make Bambuddy show a message. The payload's ``serial`` only goes
|
||||
up; a feed older than one already accepted is refused, so an old signed file
|
||||
cannot be re-served to bring back a withdrawn message.
|
||||
|
||||
The feed is the full current list. On every accepted fetch the stored set is
|
||||
replaced: a message withdrawn upstream disappears here with its read markers.
|
||||
Any failure keeps the last good list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.config import APP_VERSION
|
||||
from backend.app.models.announcement import Announcement, AnnouncementRead
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FEED_URL = "https://raw.githubusercontent.com/maziggy/bambuddy-notifications/main/feed.json"
|
||||
FEED_FORMAT = 1
|
||||
|
||||
# key_id (first 16 hex of sha256 of the raw public key) -> base64 raw public key.
|
||||
# A list so the key can be rotated: ship the new key next to the old one first.
|
||||
TRUSTED_KEYS: dict[str, str] = {
|
||||
"d70b3bf207fdfd59": "uQqUAYrInmXQ1nIwOIY/95L7tD5o/HZMmZfhAlULJ/w=",
|
||||
}
|
||||
|
||||
# Where a message may link to. The registrar refuses anything else too; this is
|
||||
# the side that counts.
|
||||
LINK_HOSTS = ("github.com", "bambuddy.cool")
|
||||
|
||||
LEVELS = ("info", "important", "critical")
|
||||
MAX_FEED_BYTES = 512 * 1024
|
||||
MAX_TITLE = 120
|
||||
MAX_BODY = 2000
|
||||
MAX_LINK_LABEL = 40
|
||||
|
||||
FETCH_INTERVAL_SECONDS = 6 * 3600
|
||||
FETCH_JITTER_SECONDS = 30 * 60
|
||||
# Let startup settle before the first fetch.
|
||||
FIRST_FETCH_DELAY_SECONDS = 60
|
||||
|
||||
ENABLED_KEY = "announcements_enabled"
|
||||
ALL_USERS_KEY = "announcements_all_users"
|
||||
# Internal state, never part of the settings API.
|
||||
SERIAL_KEY = "announcements_feed_serial"
|
||||
LAST_FETCH_KEY = "announcements_last_fetch"
|
||||
|
||||
|
||||
class FeedRejected(Exception):
|
||||
"""The fetched file is not a feed this install accepts. Nothing changes."""
|
||||
|
||||
|
||||
# ---- verification -------------------------------------------------------------------
|
||||
|
||||
|
||||
def canonical(payload: dict) -> bytes:
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
|
||||
|
||||
def verify_feed(content: bytes, trusted_keys: dict[str, str] | None = None) -> dict:
|
||||
"""The payload of a correctly signed feed, or FeedRejected."""
|
||||
keys = TRUSTED_KEYS if trusted_keys is None else trusted_keys
|
||||
try:
|
||||
envelope = json.loads(content)
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise FeedRejected("not JSON") from exc
|
||||
if not isinstance(envelope, dict) or envelope.get("format") != FEED_FORMAT:
|
||||
raise FeedRejected("unknown feed format")
|
||||
public = keys.get(str(envelope.get("key_id")))
|
||||
if public is None:
|
||||
raise FeedRejected(f"signed with an unknown key ({envelope.get('key_id')!r})")
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
raise FeedRejected("no payload")
|
||||
try:
|
||||
signature = base64.b64decode(str(envelope.get("signature")), validate=True)
|
||||
Ed25519PublicKey.from_public_bytes(base64.b64decode(public)).verify(signature, canonical(payload))
|
||||
except (ValueError, InvalidSignature) as exc:
|
||||
raise FeedRejected("signature does not verify") from exc
|
||||
if payload.get("format") != FEED_FORMAT:
|
||||
raise FeedRejected("unknown payload format")
|
||||
serial = payload.get("serial")
|
||||
if not isinstance(serial, int) or isinstance(serial, bool) or serial < 1:
|
||||
raise FeedRejected("no serial")
|
||||
if not isinstance(payload.get("announcements"), list):
|
||||
raise FeedRejected("no announcement list")
|
||||
return payload
|
||||
|
||||
|
||||
# ---- what this install is ------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstallFacts:
|
||||
version: str
|
||||
channel: str # stable | beta
|
||||
install_type: str # docker | native | ha_addon | windows
|
||||
|
||||
|
||||
def _version_key(version: str) -> tuple:
|
||||
"""Sortable form that puts a release above its own betas (1.2.6 > 1.2.6b3)."""
|
||||
from backend.app.api.routes.updates import parse_version
|
||||
|
||||
parsed = parse_version(version)
|
||||
major, minor, patch, micro = (parsed + (0, 0, 0, 0))[:4]
|
||||
is_prerelease = parsed[4] if len(parsed) > 4 else 0
|
||||
prerelease_num = parsed[5] if len(parsed) > 5 else 0
|
||||
return (major, minor, patch, micro, 1 - is_prerelease, prerelease_num)
|
||||
|
||||
|
||||
def _install_type() -> str:
|
||||
from backend.app.api.routes import updates
|
||||
|
||||
if updates._is_windows_installer_install():
|
||||
return "windows"
|
||||
if updates._is_ha_addon():
|
||||
return "ha_addon"
|
||||
if updates._is_docker_environment():
|
||||
return "docker"
|
||||
return "native"
|
||||
|
||||
|
||||
async def install_facts(db: AsyncSession) -> InstallFacts:
|
||||
beta_setting = (
|
||||
await db.execute(select(Settings.value).where(Settings.key == "include_beta_updates"))
|
||||
).scalar_one_or_none()
|
||||
# Beta testers are the installs that asked for betas, and the ones running one.
|
||||
prerelease = bool(re.search(r"[a-zA-Z]", APP_VERSION.lstrip("v")))
|
||||
beta = prerelease or (beta_setting or "").lower() == "true"
|
||||
return InstallFacts(version=APP_VERSION, channel="beta" if beta else "stable", install_type=_install_type())
|
||||
|
||||
|
||||
def targets(entry: dict, facts: InstallFacts) -> bool:
|
||||
target = entry.get("target") or {}
|
||||
if not isinstance(target, dict):
|
||||
return False
|
||||
try:
|
||||
here = _version_key(facts.version)
|
||||
low, high = target.get("min_version"), target.get("max_version")
|
||||
if low and here < _version_key(str(low)):
|
||||
return False
|
||||
if high and here > _version_key(str(high)):
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
channels = target.get("channels") or []
|
||||
if channels and facts.channel not in channels:
|
||||
return False
|
||||
install_types = target.get("install_types") or []
|
||||
return not (install_types and facts.install_type not in install_types)
|
||||
|
||||
|
||||
# ---- one entry, defensively ---------------------------------------------------------
|
||||
|
||||
|
||||
def link_allowed(url: str) -> bool:
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
return False
|
||||
host = (parts.hostname or "").lower()
|
||||
if parts.scheme != "https" or not host or parts.username or parts.password:
|
||||
return False
|
||||
if port not in (None, 443):
|
||||
return False
|
||||
return any(host == allowed or host.endswith("." + allowed) for allowed in LINK_HOSTS)
|
||||
|
||||
|
||||
def _parse_time(value: object) -> datetime | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def _clean_texts(texts: object) -> dict[str, dict[str, str]] | None:
|
||||
"""Plain strings, cut to length. None when there is no usable English text."""
|
||||
if not isinstance(texts, dict):
|
||||
return None
|
||||
cleaned: dict[str, dict[str, str]] = {}
|
||||
for lang, text in texts.items():
|
||||
if not isinstance(lang, str) or len(lang) > 10 or not isinstance(text, dict):
|
||||
continue
|
||||
title, body = text.get("title"), text.get("body")
|
||||
if not isinstance(title, str) or not isinstance(body, str) or not title.strip() or not body.strip():
|
||||
continue
|
||||
entry = {"title": title.strip()[:MAX_TITLE], "body": body.strip()[:MAX_BODY]}
|
||||
label = text.get("link_label")
|
||||
if isinstance(label, str) and label.strip():
|
||||
entry["link_label"] = label.strip()[:MAX_LINK_LABEL]
|
||||
cleaned[lang] = entry
|
||||
return cleaned if "en" in cleaned else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
public_id: str
|
||||
level: str
|
||||
texts: dict[str, dict[str, str]]
|
||||
link_url: str | None
|
||||
published_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
def parse_entry(raw: object) -> Entry | None:
|
||||
"""One feed entry, or None if it can't be shown safely. Never raises."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
public_id = raw.get("id")
|
||||
if not isinstance(public_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", public_id):
|
||||
return None
|
||||
level = raw.get("level") if raw.get("level") in LEVELS else "info"
|
||||
texts = _clean_texts(raw.get("texts"))
|
||||
if texts is None:
|
||||
return None
|
||||
link = raw.get("link_url")
|
||||
link = link if isinstance(link, str) and link_allowed(link) else None
|
||||
return Entry(
|
||||
public_id=public_id,
|
||||
level=level,
|
||||
texts=texts,
|
||||
link_url=link,
|
||||
published_at=_parse_time(raw.get("published_at")),
|
||||
expires_at=_parse_time(raw.get("expires_at")),
|
||||
)
|
||||
|
||||
|
||||
# ---- store ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get(db: AsyncSession, key: str) -> str | None:
|
||||
return (await db.execute(select(Settings.value).where(Settings.key == key))).scalar_one_or_none()
|
||||
|
||||
|
||||
async def _set(db: AsyncSession, key: str, value: str) -> None:
|
||||
from backend.app.core.db_dialect import upsert_setting
|
||||
|
||||
await upsert_setting(db, Settings, key, value)
|
||||
|
||||
|
||||
async def is_enabled(db: AsyncSession) -> bool:
|
||||
return (await _get(db, ENABLED_KEY) or "true").lower() != "false"
|
||||
|
||||
|
||||
async def apply_payload(db: AsyncSession, payload: dict, facts: InstallFacts) -> int:
|
||||
"""Replace the stored announcements with the ones in ``payload`` that apply here.
|
||||
|
||||
Refuses (FeedRejected) a serial below the highest already accepted. The same
|
||||
serial again is the same feed fetched twice, and is fine. Returns how many are
|
||||
stored. The caller commits.
|
||||
"""
|
||||
serial = payload["serial"]
|
||||
seen = int(await _get(db, SERIAL_KEY) or 0)
|
||||
if serial < seen:
|
||||
raise FeedRejected(f"serial {serial} is older than {seen}, already accepted")
|
||||
|
||||
wanted: dict[str, Entry] = {}
|
||||
for raw in payload["announcements"]:
|
||||
entry = parse_entry(raw)
|
||||
if entry is not None and targets(raw, facts):
|
||||
wanted[entry.public_id] = entry
|
||||
|
||||
existing = {a.public_id: a for a in (await db.execute(select(Announcement))).scalars().all()}
|
||||
gone = [a.id for pid, a in existing.items() if pid not in wanted]
|
||||
if gone:
|
||||
# Read markers first: SQLite enforces no foreign keys unless asked to, so
|
||||
# ON DELETE CASCADE alone would leave them behind.
|
||||
await db.execute(delete(AnnouncementRead).where(AnnouncementRead.announcement_id.in_(gone)))
|
||||
await db.execute(delete(Announcement).where(Announcement.id.in_(gone)))
|
||||
for pid, entry in wanted.items():
|
||||
row = existing.get(pid) or Announcement(public_id=pid)
|
||||
row.level = entry.level
|
||||
row.texts = json.dumps(entry.texts, ensure_ascii=False)
|
||||
row.link_url = entry.link_url
|
||||
row.published_at = entry.published_at
|
||||
row.expires_at = entry.expires_at
|
||||
if pid not in existing:
|
||||
db.add(row)
|
||||
await _set(db, SERIAL_KEY, str(max(serial, seen)))
|
||||
return len(wanted)
|
||||
|
||||
|
||||
async def _download() -> bytes:
|
||||
# No version and no install identity in the request: the generic agent says
|
||||
# what is asking, nothing about which install.
|
||||
headers = {"User-Agent": "Bambuddy-Announcements", "Cache-Control": "no-cache"}
|
||||
async with (
|
||||
httpx.AsyncClient(timeout=15, follow_redirects=False) as client,
|
||||
client.stream("GET", FEED_URL, headers=headers) as response,
|
||||
):
|
||||
if response.status_code != 200:
|
||||
raise FeedRejected(f"HTTP {response.status_code}")
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if size > MAX_FEED_BYTES:
|
||||
raise FeedRejected("larger than the feed is allowed to be")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
async def refresh(db: AsyncSession) -> int | None:
|
||||
"""Fetch, verify and store. Returns how many apply here, or None if nothing changed.
|
||||
|
||||
Never raises for a network problem or a bad feed -- those are logged and the
|
||||
last good list stays. A switched-off install fetches nothing.
|
||||
"""
|
||||
if not await is_enabled(db):
|
||||
return None
|
||||
try:
|
||||
content = await _download()
|
||||
payload = verify_feed(content)
|
||||
count = await apply_payload(db, payload, await install_facts(db))
|
||||
except FeedRejected as exc:
|
||||
await db.rollback()
|
||||
logger.warning("Announcements feed ignored: %s", exc)
|
||||
return None
|
||||
except httpx.HTTPError as exc:
|
||||
await db.rollback()
|
||||
# Offline and air-gapped installs land here every time; not worth a warning.
|
||||
logger.info("Announcements feed not fetched: %s", type(exc).__name__)
|
||||
return None
|
||||
await _set(db, LAST_FETCH_KEY, datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||
await db.commit()
|
||||
logger.info("Announcements feed #%s: %d for this install", payload["serial"], count)
|
||||
return count
|
||||
|
||||
|
||||
# ---- read state ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _visible_filter(now: datetime):
|
||||
return (Announcement.expires_at.is_(None)) | (Announcement.expires_at > now)
|
||||
|
||||
|
||||
async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]:
|
||||
"""Live announcements, newest first, each with whether this user has read it."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Announcement)
|
||||
.where(_visible_filter(now))
|
||||
.order_by(Announcement.published_at.desc(), Announcement.id.desc())
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
reader = AnnouncementRead.user_id.is_(None) if user_id is None else AnnouncementRead.user_id == user_id
|
||||
read_ids = set((await db.execute(select(AnnouncementRead.announcement_id).where(reader))).scalars().all())
|
||||
result = []
|
||||
for a in rows:
|
||||
try:
|
||||
texts = json.loads(a.texts)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"id": a.public_id,
|
||||
"level": a.level,
|
||||
"texts": texts,
|
||||
"link_url": a.link_url,
|
||||
"published_at": a.published_at.isoformat() + "Z" if a.published_at else None,
|
||||
"expires_at": a.expires_at.isoformat() + "Z" if a.expires_at else None,
|
||||
"read": a.id in read_ids,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def mark_read(db: AsyncSession, public_id: str, user_id: int | None) -> bool:
|
||||
"""Record that this user read it. False if there is no such announcement."""
|
||||
announcement = (
|
||||
await db.execute(select(Announcement).where(Announcement.public_id == public_id))
|
||||
).scalar_one_or_none()
|
||||
if announcement is None:
|
||||
return False
|
||||
reader = AnnouncementRead.user_id.is_(None) if user_id is None else AnnouncementRead.user_id == user_id
|
||||
# Checked rather than left to the unique constraint: NULL user_ids never
|
||||
# collide in one, so the auth-off row would be duplicated on every click.
|
||||
already = (
|
||||
await db.execute(select(AnnouncementRead.id).where(AnnouncementRead.announcement_id == announcement.id, reader))
|
||||
).first()
|
||||
if already is None:
|
||||
db.add(AnnouncementRead(announcement_id=announcement.id, user_id=user_id))
|
||||
return True
|
||||
|
||||
|
||||
# ---- background loop -----------------------------------------------------------------
|
||||
|
||||
_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def _loop() -> None:
|
||||
from backend.app.core.database import async_session
|
||||
|
||||
await asyncio.sleep(FIRST_FETCH_DELAY_SECONDS)
|
||||
while True:
|
||||
try:
|
||||
async with async_session() as db:
|
||||
await refresh(db)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Announcements refresh failed")
|
||||
await asyncio.sleep(FETCH_INTERVAL_SECONDS + random.uniform(0, FETCH_JITTER_SECONDS))
|
||||
|
||||
|
||||
def start() -> None:
|
||||
global _task
|
||||
if _task is None:
|
||||
_task = asyncio.create_task(_loop())
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
global _task
|
||||
if _task is not None:
|
||||
_task.cancel()
|
||||
_task = None
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Who sees announcements, and per-user read state.
|
||||
|
||||
Administrators see them; other signed-in users only when
|
||||
``announcements_all_users`` is on. With authentication off, the person running
|
||||
Bambuddy sees them. Anyone else gets an empty list, which hides the sidebar entry
|
||||
and the banner the same way "nothing published" does.
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.models.announcement import Announcement
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
URL = "/api/v1/announcements"
|
||||
_FIXTURE_PW = "Aa1!" + secrets.token_urlsafe(12) # pragma: allowlist secret
|
||||
|
||||
|
||||
async def _seed(db, *public_ids: str, level: str = "important") -> None:
|
||||
for pid in public_ids:
|
||||
db.add(
|
||||
Announcement(
|
||||
public_id=pid,
|
||||
level=level,
|
||||
texts=json.dumps({"en": {"title": f"T {pid}", "body": "B"}, "de": {"title": "D", "body": "B"}}),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _setting(db, key: str, value: str) -> None:
|
||||
db.add(Settings(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _admin(client: AsyncClient, username: str) -> dict:
|
||||
await client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW},
|
||||
)
|
||||
login = await client.post("/api/v1/auth/login", json={"username": username, "password": _FIXTURE_PW})
|
||||
assert login.status_code == 200, login.text
|
||||
return {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
|
||||
async def _user(client: AsyncClient, admin: dict, username: str) -> dict:
|
||||
grp = await client.post(
|
||||
"/api/v1/groups/", headers=admin, json={"name": f"ann_{username}", "permissions": ["printers:read"]}
|
||||
)
|
||||
assert grp.status_code == 201, grp.text
|
||||
created = await client.post(
|
||||
"/api/v1/users/",
|
||||
headers=admin,
|
||||
json={"username": username, "password": _FIXTURE_PW, "role": "user", "group_ids": [grp.json()["id"]]},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
login = await client.post("/api/v1/auth/login", json={"username": username, "password": _FIXTURE_PW})
|
||||
return {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAuthOff:
|
||||
@pytest.mark.asyncio
|
||||
async def test_listed_with_every_language_and_unread(self, async_client: AsyncClient, db_session):
|
||||
await _seed(db_session, "a1")
|
||||
r = await async_client.get(URL)
|
||||
assert r.status_code == 200
|
||||
[item] = r.json()
|
||||
assert item["id"] == "a1" and item["level"] == "important" and item["read"] is False
|
||||
assert set(item["texts"]) == {"en", "de"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_sticks(self, async_client: AsyncClient, db_session):
|
||||
await _seed(db_session, "a1")
|
||||
assert (await async_client.post(f"{URL}/a1/read")).status_code == 204
|
||||
assert (await async_client.get(URL)).json()[0]["read"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_id_is_404(self, async_client: AsyncClient):
|
||||
assert (await async_client.post(f"{URL}/nope/read")).status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switched_off_shows_nothing(self, async_client: AsyncClient, db_session):
|
||||
await _seed(db_session, "a1")
|
||||
await _setting(db_session, "announcements_enabled", "false")
|
||||
assert (await async_client.get(URL)).json() == []
|
||||
assert (await async_client.post(f"{URL}/a1/read")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAuthOn:
|
||||
@pytest.mark.asyncio
|
||||
async def test_signed_out_is_401(self, async_client: AsyncClient, db_session):
|
||||
await _admin(async_client, "annadmin0")
|
||||
assert (await async_client.get(URL)).status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_sees_them_a_user_does_not(self, async_client: AsyncClient, db_session):
|
||||
await _seed(db_session, "a1")
|
||||
admin = await _admin(async_client, "annadmin1")
|
||||
user = await _user(async_client, admin, "annuser1")
|
||||
assert [i["id"] for i in (await async_client.get(URL, headers=admin)).json()] == ["a1"]
|
||||
assert (await async_client.get(URL, headers=user)).json() == []
|
||||
assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_users_setting_shows_them_with_separate_read_state(self, async_client: AsyncClient, db_session):
|
||||
await _seed(db_session, "a1")
|
||||
admin = await _admin(async_client, "annadmin2")
|
||||
user = await _user(async_client, admin, "annuser2")
|
||||
await _setting(db_session, "announcements_all_users", "true")
|
||||
|
||||
assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 204
|
||||
assert (await async_client.get(URL, headers=user)).json()[0]["read"] is True
|
||||
assert (await async_client.get(URL, headers=admin)).json()[0]["read"] is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSettings:
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_switches_round_trip(self, async_client: AsyncClient):
|
||||
defaults = (await async_client.get("/api/v1/settings/")).json()
|
||||
assert defaults["announcements_enabled"] is True
|
||||
assert defaults["announcements_all_users"] is False
|
||||
r = await async_client.put(
|
||||
"/api/v1/settings/", json={"announcements_enabled": False, "announcements_all_users": True}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
after = (await async_client.get("/api/v1/settings/")).json()
|
||||
assert after["announcements_enabled"] is False
|
||||
assert after["announcements_all_users"] is True
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Announcements: verifying the signed feed, and keeping only what applies here.
|
||||
|
||||
The feed is signed by the maintainers' registrar. These tests sign their own
|
||||
feeds with a throwaway key passed in as the trusted one, and check the rules an
|
||||
install applies: a bad signature, an unknown key or an older serial changes
|
||||
nothing; a message that does not target this install is not stored; a withdrawn
|
||||
one disappears with its read markers.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.announcement import Announcement, AnnouncementRead
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services import announcements as svc
|
||||
from backend.app.services.announcements import FeedRejected, InstallFacts
|
||||
|
||||
KEY = Ed25519PrivateKey.generate()
|
||||
_PUB = KEY.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
KEY_ID = hashlib.sha256(_PUB).hexdigest()[:16]
|
||||
TRUSTED = {KEY_ID: base64.b64encode(_PUB).decode()}
|
||||
|
||||
STABLE_DOCKER = InstallFacts(version="1.2.6", channel="stable", install_type="docker")
|
||||
|
||||
|
||||
def entry(public_id="a1", level="info", target=None, **extra):
|
||||
raw = {
|
||||
"id": public_id,
|
||||
"level": level,
|
||||
"published_at": "2026-10-01T12:00:00Z",
|
||||
"expires_at": None,
|
||||
"texts": {"en": {"title": f"Title {public_id}", "body": "Body"}},
|
||||
"link_url": None,
|
||||
"target": target or {"min_version": None, "max_version": None, "channels": [], "install_types": []},
|
||||
}
|
||||
raw.update(extra)
|
||||
return raw
|
||||
|
||||
|
||||
def payload(*entries, serial=1):
|
||||
return {"format": 1, "serial": serial, "published_at": "2026-10-01T12:00:00Z", "announcements": list(entries)}
|
||||
|
||||
|
||||
def signed(p: dict, key: Ed25519PrivateKey = KEY, key_id: str = KEY_ID) -> bytes:
|
||||
"""The file exactly as the registrar writes it: pretty, payload as an object."""
|
||||
sig = key.sign(svc.canonical(p))
|
||||
envelope = {"format": 1, "key_id": key_id, "signature": base64.b64encode(sig).decode(), "payload": p}
|
||||
return json.dumps(envelope, indent=2, ensure_ascii=False).encode()
|
||||
|
||||
|
||||
class TestVerify:
|
||||
def test_a_correctly_signed_feed_verifies(self):
|
||||
p = payload(entry(texts={"en": {"title": "Grüße — 你好", "body": "x"}}))
|
||||
assert svc.verify_feed(signed(p), TRUSTED) == p
|
||||
|
||||
def test_tampered_text_is_refused(self):
|
||||
content = signed(payload(entry()))
|
||||
with pytest.raises(FeedRejected, match="signature"):
|
||||
svc.verify_feed(content.replace(b"Title a1", b"Title b1"), TRUSTED)
|
||||
|
||||
def test_another_key_is_refused(self):
|
||||
stranger = Ed25519PrivateKey.generate()
|
||||
with pytest.raises(FeedRejected, match="signature"):
|
||||
svc.verify_feed(signed(payload(entry()), key=stranger), TRUSTED)
|
||||
|
||||
def test_unknown_key_id_is_refused(self):
|
||||
with pytest.raises(FeedRejected, match="unknown key"):
|
||||
svc.verify_feed(signed(payload(entry()), key_id="0000000000000000"), TRUSTED)
|
||||
|
||||
@pytest.mark.parametrize("content", [b"", b"not json", b"[]", b'{"format": 2}'])
|
||||
def test_garbage_is_refused(self, content):
|
||||
with pytest.raises(FeedRejected):
|
||||
svc.verify_feed(content, TRUSTED)
|
||||
|
||||
@pytest.mark.parametrize("serial", [None, 0, "3", True])
|
||||
def test_a_payload_without_a_usable_serial_is_refused(self, serial):
|
||||
p = payload(entry())
|
||||
p["serial"] = serial
|
||||
with pytest.raises(FeedRejected, match="serial"):
|
||||
svc.verify_feed(signed(p), TRUSTED)
|
||||
|
||||
def test_the_built_in_key_is_well_formed(self):
|
||||
for key_id, public in svc.TRUSTED_KEYS.items():
|
||||
raw = base64.b64decode(public)
|
||||
assert len(raw) == 32
|
||||
assert hashlib.sha256(raw).hexdigest()[:16] == key_id
|
||||
|
||||
|
||||
class TestEntries:
|
||||
@pytest.mark.parametrize("public_id", [None, "", "a b", "x" * 65, 7, "../x"])
|
||||
def test_unusable_ids_are_dropped(self, public_id):
|
||||
assert svc.parse_entry(entry(public_id=public_id)) is None
|
||||
|
||||
def test_no_english_text_is_dropped(self):
|
||||
assert svc.parse_entry(entry(texts={"de": {"title": "Hallo", "body": "x"}})) is None
|
||||
|
||||
def test_texts_are_cut_to_length_and_bad_languages_skipped(self):
|
||||
e = svc.parse_entry(
|
||||
entry(
|
||||
texts={
|
||||
"en": {"title": "t" * 500, "body": "b" * 5000, "link_label": "l" * 100},
|
||||
"de": {"title": "", "body": "x"},
|
||||
"fr": "not a dict",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert set(e.texts) == {"en"}
|
||||
assert len(e.texts["en"]["title"]) == svc.MAX_TITLE
|
||||
assert len(e.texts["en"]["body"]) == svc.MAX_BODY
|
||||
assert len(e.texts["en"]["link_label"]) == svc.MAX_LINK_LABEL
|
||||
|
||||
def test_unknown_level_is_info(self):
|
||||
assert svc.parse_entry(entry(level="apocalyptic")).level == "info"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://bambuddy.cool/",
|
||||
"https://evil.example/",
|
||||
"https://bambuddy.cool.evil.example/",
|
||||
"https://user@github.com/",
|
||||
"https://github.com:8443/",
|
||||
"javascript:alert(1)",
|
||||
],
|
||||
)
|
||||
def test_links_off_the_allowlist_are_dropped_not_the_message(self, url):
|
||||
e = svc.parse_entry(entry(link_url=url))
|
||||
assert e is not None and e.link_url is None
|
||||
|
||||
def test_allowed_link_is_kept(self):
|
||||
assert svc.parse_entry(entry(link_url="https://wiki.bambuddy.cool/x/")).link_url == (
|
||||
"https://wiki.bambuddy.cool/x/"
|
||||
)
|
||||
|
||||
def test_times_are_naive_utc(self):
|
||||
e = svc.parse_entry(entry(expires_at="2026-11-01T02:00:00+02:00"))
|
||||
assert e.expires_at == datetime(2026, 11, 1, 0, 0)
|
||||
|
||||
|
||||
class TestTargeting:
|
||||
@pytest.mark.parametrize(
|
||||
"version, low, high, shown",
|
||||
[
|
||||
("1.2.6", None, None, True),
|
||||
("1.2.6", "1.2.6", None, True),
|
||||
("1.2.6", "1.2.7", None, False),
|
||||
("1.2.6", None, "1.2.5", False),
|
||||
("1.2.6b1", "1.2.6", None, False), # a beta is older than its release
|
||||
("1.2.6b1", None, "1.2.6", True),
|
||||
("1.2.6b3", "1.2.6b2", "1.2.6b3", True),
|
||||
],
|
||||
)
|
||||
def test_version_range(self, version, low, high, shown):
|
||||
facts = InstallFacts(version=version, channel="stable", install_type="docker")
|
||||
target = {"min_version": low, "max_version": high}
|
||||
assert svc.targets({"target": target}, facts) is shown
|
||||
|
||||
def test_channel(self):
|
||||
beta_only = {"target": {"channels": ["beta"]}}
|
||||
assert not svc.targets(beta_only, STABLE_DOCKER)
|
||||
assert svc.targets(beta_only, InstallFacts("1.2.6", "beta", "docker"))
|
||||
|
||||
def test_install_type(self):
|
||||
windows_only = {"target": {"install_types": ["windows"]}}
|
||||
assert not svc.targets(windows_only, STABLE_DOCKER)
|
||||
assert svc.targets(windows_only, InstallFacts("1.2.6", "stable", "windows"))
|
||||
|
||||
def test_a_malformed_target_shows_nothing(self):
|
||||
assert not svc.targets({"target": "everyone"}, STABLE_DOCKER)
|
||||
|
||||
|
||||
async def _stored(db) -> list[str]:
|
||||
return sorted((await db.execute(select(Announcement.public_id))).scalars().all())
|
||||
|
||||
|
||||
async def _serial(db) -> str | None:
|
||||
return (await db.execute(select(Settings.value).where(Settings.key == svc.SERIAL_KEY))).scalar_one_or_none()
|
||||
|
||||
|
||||
class TestApply:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_only_what_targets_this_install(self, db_session):
|
||||
p = payload(
|
||||
entry("everyone"),
|
||||
entry("beta", target={"channels": ["beta"]}),
|
||||
entry("windows", target={"install_types": ["windows"]}),
|
||||
entry("broken", texts={}),
|
||||
)
|
||||
assert await svc.apply_payload(db_session, p, STABLE_DOCKER) == 1
|
||||
await db_session.commit()
|
||||
assert await _stored(db_session) == ["everyone"]
|
||||
assert await _serial(db_session) == "1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_withdrawn_upstream_goes_with_its_read_markers(self, db_session):
|
||||
await svc.apply_payload(db_session, payload(entry("keep"), entry("drop")), STABLE_DOCKER)
|
||||
await db_session.commit()
|
||||
assert await svc.mark_read(db_session, "drop", None)
|
||||
await db_session.commit()
|
||||
|
||||
await svc.apply_payload(db_session, payload(entry("keep"), serial=2), STABLE_DOCKER)
|
||||
await db_session.commit()
|
||||
assert await _stored(db_session) == ["keep"]
|
||||
assert (await db_session.execute(select(AnnouncementRead))).scalars().all() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_edit_keeps_the_read_state(self, db_session):
|
||||
await svc.apply_payload(db_session, payload(entry("a1")), STABLE_DOCKER)
|
||||
await db_session.commit()
|
||||
await svc.mark_read(db_session, "a1", None)
|
||||
await db_session.commit()
|
||||
edited = entry("a1", texts={"en": {"title": "Fixed typo", "body": "Body"}})
|
||||
await svc.apply_payload(db_session, payload(edited, serial=2), STABLE_DOCKER)
|
||||
await db_session.commit()
|
||||
[item] = await svc.list_for(db_session, None)
|
||||
assert item["texts"]["en"]["title"] == "Fixed typo"
|
||||
assert item["read"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_older_serial_is_refused_and_changes_nothing(self, db_session):
|
||||
await svc.apply_payload(db_session, payload(entry("new"), serial=5), STABLE_DOCKER)
|
||||
await db_session.commit()
|
||||
with pytest.raises(FeedRejected, match="older"):
|
||||
await svc.apply_payload(db_session, payload(entry("old"), serial=4), STABLE_DOCKER)
|
||||
await db_session.rollback()
|
||||
assert await _stored(db_session) == ["new"]
|
||||
assert await _serial(db_session) == "5"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_same_serial_again_is_fine(self, db_session):
|
||||
await svc.apply_payload(db_session, payload(entry("a1"), serial=3), STABLE_DOCKER)
|
||||
await svc.apply_payload(db_session, payload(entry("a1"), serial=3), STABLE_DOCKER)
|
||||
await db_session.commit()
|
||||
assert await _stored(db_session) == ["a1"]
|
||||
|
||||
|
||||
class TestReadState:
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_ones_are_not_listed(self, db_session):
|
||||
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
future = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
p = payload(entry("gone", expires_at=past), entry("live", expires_at=future), entry("forever"))
|
||||
await svc.apply_payload(db_session, p, STABLE_DOCKER)
|
||||
await db_session.commit()
|
||||
assert sorted(i["id"] for i in await svc.list_for(db_session, None)) == ["forever", "live"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reading_twice_with_auth_off_records_once(self, db_session):
|
||||
await svc.apply_payload(db_session, payload(entry("a1")), STABLE_DOCKER)
|
||||
await db_session.commit()
|
||||
assert await svc.mark_read(db_session, "a1", None)
|
||||
await db_session.commit()
|
||||
assert await svc.mark_read(db_session, "a1", None)
|
||||
await db_session.commit()
|
||||
assert len((await db_session.execute(select(AnnouncementRead))).scalars().all()) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_id_is_not_marked(self, db_session):
|
||||
assert not await svc.mark_read(db_session, "nope", None)
|
||||
|
||||
|
||||
class TestRefresh:
|
||||
@pytest.mark.asyncio
|
||||
async def test_switched_off_fetches_nothing(self, db_session):
|
||||
db_session.add(Settings(key=svc.ENABLED_KEY, value="false"))
|
||||
await db_session.commit()
|
||||
with patch.object(svc, "_download", AsyncMock()) as download:
|
||||
assert await svc.refresh(db_session) is None
|
||||
download.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_good_feed_is_stored(self, db_session):
|
||||
with (
|
||||
patch.object(svc, "_download", AsyncMock(return_value=signed(payload(entry("a1"))))),
|
||||
patch.object(svc, "TRUSTED_KEYS", TRUSTED),
|
||||
patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)),
|
||||
):
|
||||
assert await svc.refresh(db_session) == 1
|
||||
assert await _stored(db_session) == ["a1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bad_feed_keeps_the_last_good_list(self, db_session):
|
||||
with (
|
||||
patch.object(svc, "TRUSTED_KEYS", TRUSTED),
|
||||
patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)),
|
||||
):
|
||||
with patch.object(svc, "_download", AsyncMock(return_value=signed(payload(entry("a1"))))):
|
||||
await svc.refresh(db_session)
|
||||
forged = signed(payload(serial=9), key=Ed25519PrivateKey.generate())
|
||||
with patch.object(svc, "_download", AsyncMock(return_value=forged)):
|
||||
assert await svc.refresh(db_session) is None
|
||||
assert await _stored(db_session) == ["a1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offline_is_quiet_and_harmless(self, db_session):
|
||||
import httpx
|
||||
|
||||
with patch.object(svc, "_download", AsyncMock(side_effect=httpx.ConnectError("offline"))):
|
||||
assert await svc.refresh(db_session) is None
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Announcements from the Bambuddy maintainers: the sidebar entry above System,
|
||||
* the slide-over list, and the banner for unread important/critical messages.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor, fireEvent, within } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { AnnouncementsPanel } from '../../components/AnnouncementsPanel';
|
||||
import { AnnouncementBanner } from '../../components/AnnouncementBanner';
|
||||
import { Layout } from '../../components/Layout';
|
||||
import type { Announcement } from '../../api/client';
|
||||
|
||||
function announcement(overrides: Partial<Announcement> = {}): Announcement {
|
||||
return {
|
||||
id: 'a1',
|
||||
level: 'info',
|
||||
texts: { en: { title: 'Hello installs', body: 'Line one\nLine two' } },
|
||||
link_url: null,
|
||||
published_at: '2026-10-01T12:00:00Z',
|
||||
expires_at: null,
|
||||
read: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AnnouncementsPanel', () => {
|
||||
it('marks what was unread as read on opening, and keeps a New chip on it', () => {
|
||||
const markRead = vi.fn();
|
||||
render(
|
||||
<AnnouncementsPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
markRead={markRead}
|
||||
announcements={[announcement(), announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'x' } } })]}
|
||||
/>
|
||||
);
|
||||
expect(markRead).toHaveBeenCalledTimes(1);
|
||||
expect(markRead).toHaveBeenCalledWith('a1');
|
||||
const items = screen.getAllByRole('listitem');
|
||||
expect(within(items[0]).getByText('New')).toBeInTheDocument();
|
||||
expect(within(items[1]).queryByText('New')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the body as plain text, never as HTML', () => {
|
||||
render(
|
||||
<AnnouncementsPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
markRead={() => {}}
|
||||
announcements={[announcement({ texts: { en: { title: 'T', body: '<img src=x onerror=alert(1)>' } } })]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('<img src=x onerror=alert(1)>')).toBeInTheDocument();
|
||||
expect(document.querySelector('img[src="x"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('links only to allowed hosts', () => {
|
||||
render(
|
||||
<AnnouncementsPanel
|
||||
open
|
||||
onClose={() => {}}
|
||||
markRead={() => {}}
|
||||
announcements={[
|
||||
announcement({ id: 'ok', link_url: 'https://wiki.bambuddy.cool/x/', texts: { en: { title: 'A', body: 'b', link_label: 'Details' } } }),
|
||||
announcement({ id: 'bad', link_url: 'https://evil.example/x', texts: { en: { title: 'B', body: 'b', link_label: 'Phish' } } }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: /Details/ });
|
||||
expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(screen.queryByRole('link', { name: /Phish/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AnnouncementsPanel open onClose={onClose} markRead={() => {}} announcements={[announcement()]} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnouncementBanner', () => {
|
||||
it('shows the first item, offers the rest, and Got it marks it read', () => {
|
||||
const markRead = vi.fn();
|
||||
const onOpen = vi.fn();
|
||||
render(
|
||||
<AnnouncementBanner
|
||||
items={[
|
||||
announcement({ id: 'crit', level: 'critical', texts: { en: { title: 'Update now', body: 'b' } } }),
|
||||
announcement({ id: 'imp', level: 'important' }),
|
||||
]}
|
||||
onOpen={onOpen}
|
||||
markRead={markRead}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Update now');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' }));
|
||||
expect(onOpen).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
|
||||
expect(markRead).toHaveBeenCalledWith('crit');
|
||||
});
|
||||
|
||||
it('renders nothing without items', () => {
|
||||
render(<AnnouncementBanner items={[]} onOpen={() => {}} markRead={() => {}} />);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout with announcements', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
|
||||
http.get('/api/v1/settings/ui-flags', () =>
|
||||
HttpResponse.json({ check_updates: false, billing_enabled: false, user_notifications_enabled: true, currency: 'EUR' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('shows no entry and no banner when there is nothing', async () => {
|
||||
render(<Layout />);
|
||||
await waitFor(() => expect(screen.getAllByTitle(/System/).length).toBeGreaterThan(0));
|
||||
expect(screen.queryByText('Announcements')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the entry with an unread count, the banner for important, and opens the list', async () => {
|
||||
let reads: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/v1/announcements', () =>
|
||||
HttpResponse.json([
|
||||
announcement({ id: 'imp', level: 'important', texts: { en: { title: 'Breaking change in 2.0', body: 'b' } } }),
|
||||
announcement({ id: 'inf', level: 'info', texts: { en: { title: 'Testers wanted', body: 'b' } } }),
|
||||
])
|
||||
),
|
||||
http.post('/api/v1/announcements/:id/read', ({ params }) => {
|
||||
reads = [...reads, String(params.id)];
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
})
|
||||
);
|
||||
render(<Layout />);
|
||||
|
||||
const entry = await screen.findByRole('button', { name: /Announcements/ });
|
||||
expect(within(entry).getByText('2')).toBeInTheDocument();
|
||||
// Only the important one earns a banner.
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0');
|
||||
expect(screen.queryByText('Testers wanted')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(entry);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('Testers wanted')).toBeInTheDocument();
|
||||
await waitFor(() => expect(reads.sort()).toEqual(['imp', 'inf']));
|
||||
// Read now: the banner is gone.
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { announcementText, isAllowedAnnouncementLink } from '../../hooks/useAnnouncements';
|
||||
import type { Announcement } from '../../api/client';
|
||||
|
||||
const a = (texts: Announcement['texts']): Announcement => ({
|
||||
id: 'x', level: 'info', texts, link_url: null, published_at: null, expires_at: null, read: false,
|
||||
});
|
||||
|
||||
describe('announcementText', () => {
|
||||
const texts = {
|
||||
en: { title: 'Hello', body: 'b' },
|
||||
de: { title: 'Hallo', body: 'b' },
|
||||
'pt-BR': { title: 'Olá', body: 'b' },
|
||||
'zh-CN': { title: '你好', body: 'b' },
|
||||
};
|
||||
|
||||
it.each([
|
||||
['de', 'Hallo'],
|
||||
['pt-BR', 'Olá'],
|
||||
['pt-br', 'Olá'],
|
||||
['pt', 'Olá'],
|
||||
['zh-TW', '你好'],
|
||||
['fr', 'Hello'],
|
||||
['en-US', 'Hello'],
|
||||
])('%s -> %s', (lang, title) => {
|
||||
expect(announcementText(a(texts), lang).title).toBe(title);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedAnnouncementLink', () => {
|
||||
it.each([
|
||||
['https://wiki.bambuddy.cool/x', true],
|
||||
['https://bambuddy.cool', true],
|
||||
['https://github.com/maziggy/bambuddy', true],
|
||||
['http://github.com/', false],
|
||||
['https://github.com.evil.example/', false],
|
||||
['https://user:pw@github.com/', false],
|
||||
['https://github.com:8443/', false],
|
||||
['javascript:alert(1)', false],
|
||||
['not a url', false],
|
||||
[null, false],
|
||||
])('%s -> %s', (url, allowed) => {
|
||||
expect(isAllowedAnnouncementLink(url)).toBe(allowed);
|
||||
});
|
||||
});
|
||||
@@ -383,6 +383,11 @@ export const handlers = [
|
||||
// Version / Health
|
||||
// ========================================================================
|
||||
|
||||
// Announcements from the maintainers: none by default.
|
||||
http.get('/api/v1/announcements', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
|
||||
http.get('/api/v1/version', () => {
|
||||
return HttpResponse.json({
|
||||
version: '0.1.5',
|
||||
|
||||
@@ -1383,6 +1383,9 @@ export interface AppSettings {
|
||||
check_updates: boolean;
|
||||
check_printer_firmware: boolean;
|
||||
include_beta_updates: boolean;
|
||||
// Announcements from the Bambuddy maintainers (a signed file on GitHub).
|
||||
announcements_enabled?: boolean;
|
||||
announcements_all_users?: boolean;
|
||||
// #1589: false hides the local username/password form on the login page;
|
||||
// BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to
|
||||
// true so the env-var recovery path is visible to the SPA.
|
||||
@@ -9196,6 +9199,32 @@ export const bugReportApi = {
|
||||
}),
|
||||
};
|
||||
|
||||
export type AnnouncementLevel = 'info' | 'important' | 'critical';
|
||||
|
||||
export interface AnnouncementText {
|
||||
title: string;
|
||||
body: string;
|
||||
link_label?: string;
|
||||
}
|
||||
|
||||
// One message from the Bambuddy maintainers. `texts` holds every language the
|
||||
// message was written in; English is always there.
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
level: AnnouncementLevel;
|
||||
texts: Record<string, AnnouncementText>;
|
||||
link_url: string | null;
|
||||
published_at: string | null;
|
||||
expires_at: string | null;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export const announcementsApi = {
|
||||
list: () => request<Announcement[]>('/announcements'),
|
||||
markRead: (id: string) =>
|
||||
request<void>(`/announcements/${encodeURIComponent(id)}/read`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
export interface SponsorPromptCheckResponse {
|
||||
show: boolean;
|
||||
milestone?: string;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Megaphone } from 'lucide-react';
|
||||
import type { Announcement } from '../api/client';
|
||||
import { announcementText } from '../hooks/useAnnouncements';
|
||||
|
||||
interface AnnouncementBannerProps {
|
||||
// Unread important/critical announcements, most severe first.
|
||||
items: Announcement[];
|
||||
onOpen: () => void;
|
||||
markRead: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The strip above the page for an unread important or critical announcement.
|
||||
*
|
||||
* One at a time, the most severe; "+N more" opens the panel. "Got it" marks it
|
||||
* read on the server, so it stays gone on every browser this user signs in on.
|
||||
* Info-level messages never get a banner -- only the sidebar dot.
|
||||
*/
|
||||
export function AnnouncementBanner({ items, onOpen, markRead }: AnnouncementBannerProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const first = items[0];
|
||||
if (!first) return null;
|
||||
const text = announcementText(first, i18n.language);
|
||||
const critical = first.level === 'critical';
|
||||
const Icon = critical ? AlertTriangle : Megaphone;
|
||||
|
||||
return (
|
||||
<div
|
||||
role={critical ? 'alert' : 'status'}
|
||||
className={`px-4 py-2 flex items-center gap-3 border-b text-sm ${
|
||||
critical
|
||||
? 'bg-red-100 dark:bg-red-500/20 border-red-300 dark:border-red-500/30'
|
||||
: 'bg-amber-100 dark:bg-amber-500/20 border-amber-300 dark:border-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${critical ? 'text-red-500' : 'text-amber-500'}`} />
|
||||
<div className="flex items-center gap-x-2 gap-y-1 flex-wrap min-w-0">
|
||||
<span className={`font-medium ${critical ? 'text-red-800 dark:text-red-200' : 'text-amber-800 dark:text-amber-200'}`}>
|
||||
{text.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className={`font-medium underline ${
|
||||
critical
|
||||
? 'text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300'
|
||||
: 'text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{items.length > 1
|
||||
? t('announcements.readMoreCount', { count: items.length - 1 })
|
||||
: t('announcements.readMore')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => markRead(first.id)}
|
||||
className="ml-auto flex-shrink-0 px-2 py-1 rounded font-medium text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
{t('announcements.gotIt')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLink, Megaphone, X } from 'lucide-react';
|
||||
import type { Announcement, AnnouncementLevel } from '../api/client';
|
||||
import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
|
||||
import { formatDateOnly } from '../utils/date';
|
||||
|
||||
const LEVEL_CHIP: Record<AnnouncementLevel, string> = {
|
||||
info: 'bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-300',
|
||||
important: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300',
|
||||
critical: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300',
|
||||
};
|
||||
|
||||
interface AnnouncementsPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
announcements: Announcement[];
|
||||
markRead: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-over list of announcements from the Bambuddy maintainers.
|
||||
*
|
||||
* Opening it marks everything in it read -- the dot and the banner go -- but the
|
||||
* ones that were unread keep a "New" chip until it closes, so the reader can
|
||||
* still tell what they came for.
|
||||
*/
|
||||
export function AnnouncementsPanel({ open, onClose, announcements, markRead }: AnnouncementsPanelProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [newIds, setNewIds] = useState<Set<string>>(() => new Set());
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
// The list at the moment of opening; later refetches don't re-mark anything.
|
||||
const latest = useRef({ announcements, markRead });
|
||||
latest.current = { announcements, markRead };
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const unreadIds = latest.current.announcements.filter((a) => !a.read).map((a) => a.id);
|
||||
setNewIds(new Set(unreadIds));
|
||||
unreadIds.forEach((id) => latest.current.markRead(id));
|
||||
closeRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="announcements-title"
|
||||
className="fixed inset-y-0 right-0 z-[61] w-full max-w-md bg-bambu-dark-secondary border-l border-bambu-dark-tertiary flex flex-col shadow-2xl"
|
||||
>
|
||||
<header className="flex items-center gap-3 px-5 py-4 border-b border-bambu-dark-tertiary">
|
||||
<Megaphone className="w-5 h-5 text-bambu-green" />
|
||||
<h2 id="announcements-title" className="text-lg font-semibold text-white">
|
||||
{t('announcements.title')}
|
||||
</h2>
|
||||
<button
|
||||
ref={closeRef}
|
||||
onClick={onClose}
|
||||
className="ml-auto p-2 -mr-2 rounded-lg text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
title={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{announcements.length === 0 ? (
|
||||
<p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-bambu-dark-tertiary">
|
||||
{announcements.map((a) => {
|
||||
const text = announcementText(a, i18n.language);
|
||||
const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
|
||||
return (
|
||||
<li key={a.id} className="px-5 py-4 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||
<span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
|
||||
{t(`announcements.level.${a.level}`)}
|
||||
</span>
|
||||
{newIds.has(a.id) && (
|
||||
<span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
|
||||
{t('announcements.new')}
|
||||
</span>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-balance">{text.title}</h3>
|
||||
<p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
|
||||
>
|
||||
{text.link_label || t('announcements.readMore')}
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
|
||||
{t('announcements.source')}
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, Megaphone, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
@@ -21,6 +21,9 @@ import { Card, CardHeader, CardContent } from './Card';
|
||||
import { parseUTCDate } from '../utils/date';
|
||||
import { Button } from './Button';
|
||||
import { BugReportBubble } from './BugReportBubble';
|
||||
import { AnnouncementsPanel } from './AnnouncementsPanel';
|
||||
import { AnnouncementBanner } from './AnnouncementBanner';
|
||||
import { useAnnouncements } from '../hooks/useAnnouncements';
|
||||
import {
|
||||
getHiddenSidebarSystemItemIds,
|
||||
getSidebarOrder,
|
||||
@@ -149,6 +152,17 @@ export function Layout() {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Announcements from the Bambuddy maintainers: the sidebar entry above System,
|
||||
// the slide-over list, and the banner for unread important/critical ones.
|
||||
const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
|
||||
useAnnouncements();
|
||||
const [announcementsOpen, setAnnouncementsOpen] = useState(false);
|
||||
const openAnnouncements = useCallback(() => {
|
||||
setMobileDrawerOpen(false);
|
||||
setAnnouncementsOpen(true);
|
||||
}, []);
|
||||
const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []);
|
||||
|
||||
// Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
|
||||
useSponsorPrompt(uiFlags?.currency ?? 'USD');
|
||||
|
||||
@@ -733,6 +747,26 @@ export function Layout() {
|
||||
<div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
|
||||
{isSidebarCompact || sidebarExpanded ? (
|
||||
<div className="flex flex-col gap-2 px-2">
|
||||
{/* Announcements: a row of its own above the icons, so it isn't one
|
||||
more glyph in a row people have stopped reading. Only while
|
||||
there is something to show. */}
|
||||
{announcements.length > 0 && (
|
||||
<button
|
||||
onClick={openAnnouncements}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white text-sm"
|
||||
>
|
||||
<Megaphone className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{t('announcements.title')}</span>
|
||||
{unreadAnnouncements.length > 0 && (
|
||||
<span
|
||||
className="ml-auto min-w-[20px] h-5 px-1.5 flex items-center justify-center text-[11px] font-bold rounded-full bg-bambu-green text-white"
|
||||
aria-label={t('announcements.unread', { count: unreadAnnouncements.length })}
|
||||
>
|
||||
{unreadAnnouncements.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Top row: icons */}
|
||||
<div className="flex items-center justify-center gap-1 flex-wrap">
|
||||
{hasSwitchbarPlugs && (
|
||||
@@ -856,6 +890,23 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{announcements.length > 0 && (
|
||||
<button
|
||||
onClick={openAnnouncements}
|
||||
className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={t('announcements.title')}
|
||||
aria-label={
|
||||
unreadAnnouncements.length > 0
|
||||
? t('announcements.unread', { count: unreadAnnouncements.length })
|
||||
: t('announcements.title')
|
||||
}
|
||||
>
|
||||
<Megaphone className="w-5 h-5" />
|
||||
{unreadAnnouncements.length > 0 && (
|
||||
<span className="absolute top-1 right-1 w-2.5 h-2.5 rounded-full bg-bambu-green ring-2 ring-bambu-dark-secondary" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{hasPermission('system:read') ? (
|
||||
<NavLink
|
||||
to="/system"
|
||||
@@ -979,6 +1030,7 @@ export function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AnnouncementBanner items={bannerItems} onOpen={openAnnouncements} markRead={markAnnouncementRead} />
|
||||
{/* Persistent update banner */}
|
||||
{showUpdateBanner && (
|
||||
<div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
|
||||
@@ -1008,6 +1060,12 @@ export function Layout() {
|
||||
)}
|
||||
<Outlet />
|
||||
</main>
|
||||
<AnnouncementsPanel
|
||||
open={announcementsOpen}
|
||||
onClose={closeAnnouncements}
|
||||
announcements={announcements}
|
||||
markRead={markAnnouncementRead}
|
||||
/>
|
||||
|
||||
<UnknownSpoolModal
|
||||
prompt={unknownSpool.prompt}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { announcementsApi, type Announcement, type AnnouncementText } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// Hosts a message may link to. The backend already drops any other link; this
|
||||
// is the second check, so a bad link never becomes a clickable one.
|
||||
const LINK_HOSTS = ['github.com', 'bambuddy.cool'];
|
||||
|
||||
export function isAllowedAnnouncementLink(url: string | null | undefined): url is string {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return false;
|
||||
if (parsed.port && parsed.port !== '443') return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return LINK_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The message in the UI language when it was written in it, else English. */
|
||||
export function announcementText(announcement: Announcement, language: string): AnnouncementText {
|
||||
const { texts } = announcement;
|
||||
if (texts[language]) return texts[language];
|
||||
const lower = language.toLowerCase();
|
||||
const sameCase = Object.keys(texts).find((key) => key.toLowerCase() === lower);
|
||||
if (sameCase) return texts[sameCase];
|
||||
// "pt" finds "pt-BR", "zh-HK" finds "zh-TW" or "zh-CN" -- closer than English.
|
||||
const base = lower.split('-')[0];
|
||||
const sameBase = Object.keys(texts).find((key) => key.toLowerCase().split('-')[0] === base);
|
||||
return sameBase ? texts[sameBase] : texts.en;
|
||||
}
|
||||
|
||||
const SEVERITY: Record<Announcement['level'], number> = { critical: 2, important: 1, info: 0 };
|
||||
|
||||
/**
|
||||
* Announcements from the Bambuddy maintainers, for whoever may see them.
|
||||
*
|
||||
* The backend answers with an empty list for anyone who may not (and when the
|
||||
* install has them switched off), so an empty list is the one signal for
|
||||
* "show nothing" -- no separate permission check here.
|
||||
*/
|
||||
export function useAnnouncements() {
|
||||
const { authEnabled, user, loading } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['announcements'],
|
||||
queryFn: announcementsApi.list,
|
||||
enabled: !loading && (!authEnabled || !!user),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// A local read of what the backend fetched; the backend itself only goes
|
||||
// to GitHub every few hours.
|
||||
refetchInterval: 10 * 60 * 1000,
|
||||
});
|
||||
const announcements = useMemo(() => data ?? [], [data]);
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: announcementsApi.markRead,
|
||||
onMutate: (id: string) => {
|
||||
queryClient.setQueryData<Announcement[]>(['announcements'], (old) =>
|
||||
old?.map((a) => (a.id === id ? { ...a, read: true } : a))
|
||||
);
|
||||
},
|
||||
onError: () => queryClient.invalidateQueries({ queryKey: ['announcements'] }),
|
||||
});
|
||||
const { mutate } = markReadMutation;
|
||||
const markRead = useCallback((id: string) => mutate(id), [mutate]);
|
||||
|
||||
const unread = useMemo(() => announcements.filter((a) => !a.read), [announcements]);
|
||||
|
||||
// What earns a banner: important or critical, not yet read. Most severe first,
|
||||
// then newest (the list already comes newest first).
|
||||
const bannerItems = useMemo(
|
||||
() =>
|
||||
unread
|
||||
.filter((a) => a.level !== 'info')
|
||||
.sort((a, b) => SEVERITY[b.level] - SEVERITY[a.level]),
|
||||
[unread]
|
||||
);
|
||||
|
||||
return { announcements, unread, bannerItems, markRead };
|
||||
}
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Ankündigungen',
|
||||
unread: 'Ungelesene Ankündigungen: {{count}}',
|
||||
empty: 'Derzeit keine Ankündigungen.',
|
||||
new: 'Neu',
|
||||
readMore: 'Mehr erfahren',
|
||||
readMoreCount: 'Mehr erfahren (+{{count}})',
|
||||
gotIt: 'Verstanden',
|
||||
source: 'Von den Bambuddy-Entwicklern, abgerufen von GitHub. Du kannst sie in den Einstellungen abschalten.',
|
||||
level: {
|
||||
info: 'Hinweis',
|
||||
important: 'Wichtig',
|
||||
critical: 'Kritisch',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} weitere',
|
||||
save: 'Speichern',
|
||||
@@ -2204,6 +2219,11 @@ export default {
|
||||
checkPrinterFirmware: 'Drucker-Firmware prüfen',
|
||||
includeBetaUpdates: 'Beta-Versionen einschließen',
|
||||
includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',
|
||||
announcementsEnabled: 'Ankündigungen empfangen',
|
||||
announcementsEnabledDesc: 'Nachrichten der Bambuddy-Entwickler, etwa zu Sicherheitskorrekturen und inkompatiblen Änderungen. Bambuddy lädt alle paar Stunden eine signierte Datei von GitHub; über diese Installation wird nichts gesendet.',
|
||||
announcementsLearnMore: 'Mehr dazu',
|
||||
announcementsAllUsers: 'Allen Benutzern anzeigen',
|
||||
announcementsAllUsersDesc: 'Aus: Nur Administratoren sehen sie. Ohne Wirkung, solange die Authentifizierung aus ist.',
|
||||
localLogin: {
|
||||
disable: 'Lokale Benutzername-/Passwort-Anmeldung deaktivieren',
|
||||
disableHint: 'Wenn aktiviert, ist nur die Anmeldung über SSO möglich. LDAP ist davon nicht betroffen. Setzen Sie BAMBUDDY_LOCAL_LOGIN=true auf dem Server, um einen Wiederherstellungsweg offen zu halten.',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
unread: 'Unread announcements: {{count}}',
|
||||
empty: 'No announcements right now.',
|
||||
new: 'New',
|
||||
readMore: 'Read more',
|
||||
readMoreCount: 'Read more (+{{count}})',
|
||||
gotIt: 'Got it',
|
||||
source: 'From the Bambuddy maintainers, fetched from GitHub. You can turn them off in Settings.',
|
||||
level: {
|
||||
info: 'Info',
|
||||
important: 'Important',
|
||||
critical: 'Critical',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} more',
|
||||
save: 'Save',
|
||||
@@ -2224,6 +2239,11 @@ export default {
|
||||
checkPrinterFirmware: 'Check printer firmware',
|
||||
includeBetaUpdates: 'Include beta versions',
|
||||
includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',
|
||||
announcementsEnabled: 'Receive announcements',
|
||||
announcementsEnabledDesc: 'Messages from the Bambuddy maintainers, such as security fixes and breaking changes. Bambuddy fetches a signed file from GitHub every few hours; nothing about this install is sent.',
|
||||
announcementsLearnMore: 'Learn more',
|
||||
announcementsAllUsers: 'Show to all users',
|
||||
announcementsAllUsersDesc: 'Off: only administrators see them. Has no effect while authentication is off.',
|
||||
localLogin: {
|
||||
disable: 'Disable local username/password login',
|
||||
disableHint: 'When enabled, only SSO providers can sign in. LDAP is unaffected. Set BAMBUDDY_LOCAL_LOGIN=true on the server to keep a recovery path.',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Anuncios',
|
||||
unread: 'Anuncios sin leer: {{count}}',
|
||||
empty: 'No hay anuncios por ahora.',
|
||||
new: 'Nuevo',
|
||||
readMore: 'Leer más',
|
||||
readMoreCount: 'Leer más (+{{count}})',
|
||||
gotIt: 'Entendido',
|
||||
source: 'De los desarrolladores de Bambuddy, obtenidos de GitHub. Puedes desactivarlos en Ajustes.',
|
||||
level: {
|
||||
info: 'Aviso',
|
||||
important: 'Importante',
|
||||
critical: 'Crítico',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} más',
|
||||
save: 'Guardar',
|
||||
@@ -2207,6 +2222,11 @@ export default {
|
||||
checkPrinterFirmware: 'Comprobar el firmware de la impresora',
|
||||
includeBetaUpdates: 'Incluir versiones beta',
|
||||
includeBetaUpdatesDesc: 'Notificar sobre versiones beta y preliminares al buscar actualizaciones',
|
||||
announcementsEnabled: 'Recibir anuncios',
|
||||
announcementsEnabledDesc: 'Mensajes de los desarrolladores de Bambuddy, como correcciones de seguridad y cambios incompatibles. Bambuddy descarga un archivo firmado de GitHub cada pocas horas; no se envía nada sobre esta instalación.',
|
||||
announcementsLearnMore: 'Más información',
|
||||
announcementsAllUsers: 'Mostrar a todos los usuarios',
|
||||
announcementsAllUsersDesc: 'Desactivado: solo los administradores los ven. No tiene efecto mientras la autenticación esté desactivada.',
|
||||
localLogin: {
|
||||
disable: 'Deshabilitar el inicio de sesión local con usuario/contraseña',
|
||||
disableHint: 'Cuando se habilita, solo los proveedores SSO pueden iniciar sesión. LDAP no se ve afectado. Defina BAMBUDDY_LOCAL_LOGIN=true en el servidor para mantener una vía de recuperación.',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Annonces',
|
||||
unread: 'Annonces non lues : {{count}}',
|
||||
empty: 'Aucune annonce pour le moment.',
|
||||
new: 'Nouveau',
|
||||
readMore: 'En savoir plus',
|
||||
readMoreCount: 'En savoir plus (+{{count}})',
|
||||
gotIt: 'Compris',
|
||||
source: 'Des développeurs de Bambuddy, récupérées depuis GitHub. Vous pouvez les désactiver dans les paramètres.',
|
||||
level: {
|
||||
info: 'Information',
|
||||
important: 'Importante',
|
||||
critical: 'Critique',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} autres',
|
||||
save: 'Enregistrer',
|
||||
@@ -2159,6 +2174,11 @@ export default {
|
||||
checkPrinterFirmware: 'Vérifier le firmware imprimante',
|
||||
includeBetaUpdates: 'Inclure les versions bêta',
|
||||
includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',
|
||||
announcementsEnabled: 'Recevoir les annonces',
|
||||
announcementsEnabledDesc: "Messages des développeurs de Bambuddy, comme les correctifs de sécurité et les changements incompatibles. Bambuddy télécharge un fichier signé depuis GitHub toutes les quelques heures ; rien sur cette installation n'est envoyé.",
|
||||
announcementsLearnMore: 'En savoir plus',
|
||||
announcementsAllUsers: 'Afficher à tous les utilisateurs',
|
||||
announcementsAllUsersDesc: "Désactivé : seuls les administrateurs les voient. Sans effet tant que l'authentification est désactivée.",
|
||||
localLogin: {
|
||||
disable: 'Désactiver la connexion locale par nom d\'utilisateur/mot de passe',
|
||||
disableHint: 'Quand activée, seuls les fournisseurs SSO peuvent se connecter. LDAP n\'est pas affecté. Définissez BAMBUDDY_LOCAL_LOGIN=true sur le serveur pour conserver une voie de récupération.',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Annunci',
|
||||
unread: 'Annunci non letti: {{count}}',
|
||||
empty: 'Nessun annuncio al momento.',
|
||||
new: 'Nuovo',
|
||||
readMore: 'Scopri di più',
|
||||
readMoreCount: 'Scopri di più (+{{count}})',
|
||||
gotIt: 'Ho capito',
|
||||
source: 'Dagli sviluppatori di Bambuddy, scaricati da GitHub. Puoi disattivarli nelle impostazioni.',
|
||||
level: {
|
||||
info: 'Informazione',
|
||||
important: 'Importante',
|
||||
critical: 'Critico',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} altri',
|
||||
save: 'Salva',
|
||||
@@ -2159,6 +2174,11 @@ export default {
|
||||
checkPrinterFirmware: 'Controlla firmware stampante',
|
||||
includeBetaUpdates: 'Includi versioni beta',
|
||||
includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',
|
||||
announcementsEnabled: 'Ricevi annunci',
|
||||
announcementsEnabledDesc: 'Messaggi degli sviluppatori di Bambuddy, come correzioni di sicurezza e modifiche incompatibili. Bambuddy scarica un file firmato da GitHub ogni poche ore; nulla su questa installazione viene inviato.',
|
||||
announcementsLearnMore: 'Ulteriori informazioni',
|
||||
announcementsAllUsers: 'Mostra a tutti gli utenti',
|
||||
announcementsAllUsersDesc: "Disattivato: li vedono solo gli amministratori. Nessun effetto finché l'autenticazione è disattivata.",
|
||||
localLogin: {
|
||||
disable: 'Disabilita l\'accesso locale con nome utente/password',
|
||||
disableHint: 'Quando attivato, solo i provider SSO possono accedere. LDAP non è interessato. Imposta BAMBUDDY_LOCAL_LOGIN=true sul server per mantenere un percorso di ripristino.',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'お知らせ',
|
||||
unread: '未読のお知らせ: {{count}}',
|
||||
empty: '現在お知らせはありません。',
|
||||
new: '新着',
|
||||
readMore: '詳細を見る',
|
||||
readMoreCount: '詳細を見る (+{{count}})',
|
||||
gotIt: '了解',
|
||||
source: 'Bambuddy 開発者からのお知らせです(GitHub から取得)。設定でオフにできます。',
|
||||
level: {
|
||||
info: '情報',
|
||||
important: '重要',
|
||||
critical: '緊急',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '他{{count}}件',
|
||||
save: '保存',
|
||||
@@ -2203,6 +2218,11 @@ export default {
|
||||
checkPrinterFirmware: 'プリンターファームウェアの確認',
|
||||
includeBetaUpdates: 'ベータ版を含める',
|
||||
includeBetaUpdatesDesc: 'アップデート確認時にベータ版およびプレリリース版を通知する',
|
||||
announcementsEnabled: 'お知らせを受け取る',
|
||||
announcementsEnabledDesc: 'セキュリティ修正や互換性のない変更など、Bambuddy 開発者からのメッセージです。Bambuddy は数時間ごとに GitHub から署名付きファイルを取得します。このインストールに関する情報は一切送信されません。',
|
||||
announcementsLearnMore: '詳細',
|
||||
announcementsAllUsers: 'すべてのユーザーに表示',
|
||||
announcementsAllUsersDesc: 'オフ: 管理者のみに表示されます。認証がオフの間は効果がありません。',
|
||||
localLogin: {
|
||||
disable: 'ローカルのユーザー名/パスワードログインを無効化',
|
||||
disableHint: '有効にすると、SSOプロバイダーのみでサインインできます。LDAPには影響しません。復旧用のパスを残すには、サーバーで BAMBUDDY_LOCAL_LOGIN=true を設定してください。',
|
||||
|
||||
@@ -30,6 +30,21 @@ export default {
|
||||
installApp: '앱 설치',
|
||||
installAppSuccess: 'Bambuddy가 설치되었습니다'
|
||||
},
|
||||
announcements: {
|
||||
title: '공지사항',
|
||||
unread: '읽지 않은 공지사항: {{count}}',
|
||||
empty: '현재 공지사항이 없습니다.',
|
||||
new: '새 소식',
|
||||
readMore: '자세히 보기',
|
||||
readMoreCount: '자세히 보기 (+{{count}})',
|
||||
gotIt: '확인',
|
||||
source: 'Bambuddy 개발자의 공지로, GitHub에서 가져옵니다. 설정에서 끌 수 있습니다.',
|
||||
level: {
|
||||
info: '정보',
|
||||
important: '중요',
|
||||
critical: '긴급',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '외 {{count}}개',
|
||||
save: '저장',
|
||||
@@ -2101,6 +2116,11 @@ export default {
|
||||
checkPrinterFirmware: '프린터 펌웨어 확인',
|
||||
includeBetaUpdates: '베타 버전 포함',
|
||||
includeBetaUpdatesDesc: '업데이트 확인 시 베타 및 사전 릴리스 버전에 대해 알림',
|
||||
announcementsEnabled: '공지사항 받기',
|
||||
announcementsEnabledDesc: '보안 수정이나 호환되지 않는 변경 사항 등 Bambuddy 개발자의 메시지입니다. Bambuddy는 몇 시간마다 GitHub에서 서명된 파일을 가져오며, 이 설치에 관한 정보는 전송하지 않습니다.',
|
||||
announcementsLearnMore: '자세히 알아보기',
|
||||
announcementsAllUsers: '모든 사용자에게 표시',
|
||||
announcementsAllUsersDesc: '꺼짐: 관리자만 볼 수 있습니다. 인증이 꺼져 있는 동안에는 효과가 없습니다.',
|
||||
localLogin: {
|
||||
disable: '로컬 사용자명/비밀번호 로그인 비활성화',
|
||||
disableHint: '활성화하면 SSO 공급자로만 로그인할 수 있습니다. LDAP는 영향을 받지 않습니다. 서버에서 BAMBUDDY_LOCAL_LOGIN=true 를 설정하면 복구 경로가 유지됩니다.'
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Aankondigingen',
|
||||
unread: 'Ongelezen aankondigingen: {{count}}',
|
||||
empty: 'Op dit moment geen aankondigingen.',
|
||||
new: 'Nieuw',
|
||||
readMore: 'Meer lezen',
|
||||
readMoreCount: 'Meer lezen (+{{count}})',
|
||||
gotIt: 'Begrepen',
|
||||
source: 'Van de Bambuddy-ontwikkelaars, opgehaald van GitHub. Je kunt ze uitzetten in de instellingen.',
|
||||
level: {
|
||||
info: 'Mededeling',
|
||||
important: 'Belangrijk',
|
||||
critical: 'Kritiek',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} meer',
|
||||
save: 'Opslaan',
|
||||
@@ -2224,6 +2239,11 @@ export default {
|
||||
checkPrinterFirmware: 'Printerfirmware controleren',
|
||||
includeBetaUpdates: 'Bètaversies opnemen',
|
||||
includeBetaUpdatesDesc: 'Melden over bèta- en prereleaseversies bij controle op updates',
|
||||
announcementsEnabled: 'Aankondigingen ontvangen',
|
||||
announcementsEnabledDesc: 'Berichten van de Bambuddy-ontwikkelaars, zoals beveiligingsfixes en incompatibele wijzigingen. Bambuddy haalt om de paar uur een ondertekend bestand op van GitHub; er wordt niets over deze installatie verstuurd.',
|
||||
announcementsLearnMore: 'Meer informatie',
|
||||
announcementsAllUsers: 'Tonen aan alle gebruikers',
|
||||
announcementsAllUsersDesc: 'Uit: alleen beheerders zien ze. Heeft geen effect zolang authenticatie uit staat.',
|
||||
localLogin: {
|
||||
disable: 'Lokale login met gebruikersnaam/wachtwoord uitschakelen',
|
||||
disableHint: 'Wanneer ingeschakeld kunnen alleen SSO-providers inloggen. LDAP blijft onaangetast. Stel BAMBUDDY_LOCAL_LOGIN=true in op de server om een herstelmogelijkheid te behouden.',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Anúncios',
|
||||
unread: 'Anúncios não lidos: {{count}}',
|
||||
empty: 'Nenhum anúncio no momento.',
|
||||
new: 'Novo',
|
||||
readMore: 'Saiba mais',
|
||||
readMoreCount: 'Saiba mais (+{{count}})',
|
||||
gotIt: 'Entendi',
|
||||
source: 'Dos desenvolvedores do Bambuddy, obtidos do GitHub. Você pode desativá-los nas configurações.',
|
||||
level: {
|
||||
info: 'Aviso',
|
||||
important: 'Importante',
|
||||
critical: 'Crítico',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} outros',
|
||||
save: 'Salvar',
|
||||
@@ -2159,6 +2174,11 @@ export default {
|
||||
checkPrinterFirmware: 'Verificar firmware da impressora',
|
||||
includeBetaUpdates: 'Incluir versões beta',
|
||||
includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',
|
||||
announcementsEnabled: 'Receber anúncios',
|
||||
announcementsEnabledDesc: 'Mensagens dos desenvolvedores do Bambuddy, como correções de segurança e mudanças incompatíveis. O Bambuddy baixa um arquivo assinado do GitHub a cada poucas horas; nada sobre esta instalação é enviado.',
|
||||
announcementsLearnMore: 'Saiba mais',
|
||||
announcementsAllUsers: 'Mostrar para todos os usuários',
|
||||
announcementsAllUsersDesc: 'Desativado: apenas administradores os veem. Sem efeito enquanto a autenticação estiver desativada.',
|
||||
localLogin: {
|
||||
disable: 'Desativar login local com usuário/senha',
|
||||
disableHint: 'Quando ativado, somente provedores SSO podem fazer login. O LDAP não é afetado. Defina BAMBUDDY_LOCAL_LOGIN=true no servidor para manter um caminho de recuperação.',
|
||||
|
||||
@@ -30,6 +30,21 @@ export default {
|
||||
installApp: "Установить приложение",
|
||||
installAppSuccess: "Bambuddy установлен",
|
||||
},
|
||||
announcements: {
|
||||
title: 'Объявления',
|
||||
unread: 'Непрочитанные объявления: {{count}}',
|
||||
empty: 'Сейчас объявлений нет.',
|
||||
new: 'Новое',
|
||||
readMore: 'Подробнее',
|
||||
readMoreCount: 'Подробнее (+{{count}})',
|
||||
gotIt: 'Понятно',
|
||||
source: 'От разработчиков Bambuddy, загружаются с GitHub. Их можно отключить в настройках.',
|
||||
level: {
|
||||
info: 'Информация',
|
||||
important: 'Важно',
|
||||
critical: 'Критично',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: 'ещё {{count}}',
|
||||
save: "Сохранить",
|
||||
@@ -2098,6 +2113,11 @@ export default {
|
||||
checkPrinterFirmware: "Проверять прошивку принтера",
|
||||
includeBetaUpdates: "Включать бета-версии",
|
||||
includeBetaUpdatesDesc: "Уведомлять о бета- и предварительных версиях при проверке обновлений",
|
||||
announcementsEnabled: 'Получать объявления',
|
||||
announcementsEnabledDesc: 'Сообщения от разработчиков Bambuddy, например об исправлениях безопасности и несовместимых изменениях. Bambuddy раз в несколько часов загружает подписанный файл с GitHub; никакие данные об этой установке не отправляются.',
|
||||
announcementsLearnMore: 'Подробнее',
|
||||
announcementsAllUsers: 'Показывать всем пользователям',
|
||||
announcementsAllUsersDesc: 'Выкл.: видят только администраторы. Не действует, пока аутентификация отключена.',
|
||||
localLogin: {
|
||||
disable: "Отключить локальный вход по имени пользователя и паролю",
|
||||
disableHint: "Если включено, вход возможен только через провайдеров SSO. На LDAP это не влияет. Чтобы сохранить аварийный способ входа, задайте на сервере BAMBUDDY_LOCAL_LOGIN=true.",
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Meddelanden',
|
||||
unread: 'Olästa meddelanden: {{count}}',
|
||||
empty: 'Inga meddelanden just nu.',
|
||||
new: 'Ny',
|
||||
readMore: 'Läs mer',
|
||||
readMoreCount: 'Läs mer (+{{count}})',
|
||||
gotIt: 'Uppfattat',
|
||||
source: 'Från Bambuddys utvecklare, hämtade från GitHub. Du kan stänga av dem i inställningarna.',
|
||||
level: {
|
||||
info: 'Information',
|
||||
important: 'Viktigt',
|
||||
critical: 'Kritiskt',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} till',
|
||||
save: 'Spara',
|
||||
@@ -2223,6 +2238,11 @@ export default {
|
||||
checkPrinterFirmware: 'Kontrollera skrivarens firmware',
|
||||
includeBetaUpdates: 'Inkludera betaversioner',
|
||||
includeBetaUpdatesDesc: 'Notis om betaversioner och förhandsversioner vid sökning av uppdateringar',
|
||||
announcementsEnabled: 'Ta emot meddelanden',
|
||||
announcementsEnabledDesc: 'Meddelanden från Bambuddys utvecklare, till exempel säkerhetsrättningar och inkompatibla ändringar. Bambuddy hämtar en signerad fil från GitHub med några timmars mellanrum; ingenting om den här installationen skickas.',
|
||||
announcementsLearnMore: 'Läs mer',
|
||||
announcementsAllUsers: 'Visa för alla användare',
|
||||
announcementsAllUsersDesc: 'Av: endast administratörer ser dem. Ingen effekt så länge autentisering är avstängd.',
|
||||
localLogin: {
|
||||
disable: 'Inaktivera lokal användarnamn/lösenordsinloggning',
|
||||
disableHint: 'När aktiverat kan endast SSO-leverantörer logga in. LDAP påverkas inte. Sätt BAMBUDDY_LOCAL_LOGIN=true på servern för att behålla en återställningsväg.',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Ortak
|
||||
announcements: {
|
||||
title: 'Duyurular',
|
||||
unread: 'Okunmamış duyurular: {{count}}',
|
||||
empty: 'Şu anda duyuru yok.',
|
||||
new: 'Yeni',
|
||||
readMore: 'Devamını oku',
|
||||
readMoreCount: 'Devamını oku (+{{count}})',
|
||||
gotIt: 'Anladım',
|
||||
source: "Bambuddy geliştiricilerinden, GitHub'dan alınır. Ayarlardan kapatabilirsiniz.",
|
||||
level: {
|
||||
info: 'Bilgi',
|
||||
important: 'Önemli',
|
||||
critical: 'Kritik',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '+{{count}} tane daha',
|
||||
save: 'Kaydet',
|
||||
@@ -2207,6 +2222,11 @@ export default {
|
||||
checkPrinterFirmware: 'Yazıcı firmware\'ini kontrol et',
|
||||
includeBetaUpdates: 'Beta sürümleri dahil et',
|
||||
includeBetaUpdatesDesc: 'Güncellemeleri kontrol ederken beta ve önyayım sürümleri hakkında bildir',
|
||||
announcementsEnabled: 'Duyuruları al',
|
||||
announcementsEnabledDesc: "Güvenlik düzeltmeleri ve uyumsuz değişiklikler gibi Bambuddy geliştiricilerinden gelen mesajlar. Bambuddy birkaç saatte bir GitHub'dan imzalı bir dosya indirir; bu kurulum hakkında hiçbir şey gönderilmez.",
|
||||
announcementsLearnMore: 'Daha fazla bilgi',
|
||||
announcementsAllUsers: 'Tüm kullanıcılara göster',
|
||||
announcementsAllUsersDesc: 'Kapalı: yalnızca yöneticiler görür. Kimlik doğrulama kapalıyken etkisi yoktur.',
|
||||
localLogin: {
|
||||
disable: 'Yerel kullanıcı adı/şifre ile oturum açmayı devre dışı bırak',
|
||||
disableHint: 'Etkinleştirildiğinde yalnızca SSO sağlayıcıları ile oturum açılabilir. LDAP etkilenmez. Bir kurtarma yolu açık tutmak için sunucuda BAMBUDDY_LOCAL_LOGIN=true ayarlayın.',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: 'Оголошення',
|
||||
unread: 'Непрочитані оголошення: {{count}}',
|
||||
empty: 'Наразі оголошень немає.',
|
||||
new: 'Нове',
|
||||
readMore: 'Докладніше',
|
||||
readMoreCount: 'Докладніше (+{{count}})',
|
||||
gotIt: 'Зрозуміло',
|
||||
source: 'Від розробників Bambuddy, завантажуються з GitHub. Їх можна вимкнути в налаштуваннях.',
|
||||
level: {
|
||||
info: 'Інформація',
|
||||
important: 'Важливо',
|
||||
critical: 'Критично',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: 'ще {{count}}',
|
||||
save: "Зберегти",
|
||||
@@ -2222,6 +2237,11 @@ export default {
|
||||
checkPrinterFirmware: "Перевірити прошивку принтера",
|
||||
includeBetaUpdates: "Включати бета-версії",
|
||||
includeBetaUpdatesDesc: "Повідомляти про бета-версії та попередні версії під час перевірки оновлень",
|
||||
announcementsEnabled: 'Отримувати оголошення',
|
||||
announcementsEnabledDesc: 'Повідомлення від розробників Bambuddy, наприклад про виправлення безпеки та несумісні зміни. Bambuddy раз на кілька годин завантажує підписаний файл із GitHub; жодних даних про цю інсталяцію не надсилається.',
|
||||
announcementsLearnMore: 'Докладніше',
|
||||
announcementsAllUsers: 'Показувати всім користувачам',
|
||||
announcementsAllUsersDesc: 'Вимк.: бачать лише адміністратори. Не діє, поки автентифікацію вимкнено.',
|
||||
localLogin: {
|
||||
disable: "Вимкнути локальний вхід за іменем користувача й паролем",
|
||||
disableHint: "Якщо ввімкнено, увійти можна буде лише через постачальників SSO. На LDAP це не впливає. Щоб зберегти резервний спосіб входу, установіть на сервері BAMBUDDY_LOCAL_LOGIN=true.",
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: '公告',
|
||||
unread: '未读公告:{{count}}',
|
||||
empty: '暂无公告。',
|
||||
new: '新',
|
||||
readMore: '了解更多',
|
||||
readMoreCount: '了解更多 (+{{count}})',
|
||||
gotIt: '知道了',
|
||||
source: '来自 Bambuddy 开发者,从 GitHub 获取。可在设置中关闭。',
|
||||
level: {
|
||||
info: '信息',
|
||||
important: '重要',
|
||||
critical: '紧急',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '另 {{count}} 个',
|
||||
save: '保存',
|
||||
@@ -2204,6 +2219,11 @@ export default {
|
||||
checkPrinterFirmware: '检查打印机固件',
|
||||
includeBetaUpdates: '包含测试版本',
|
||||
includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
|
||||
announcementsEnabled: '接收公告',
|
||||
announcementsEnabledDesc: '来自 Bambuddy 开发者的消息,例如安全修复和不兼容的变更。Bambuddy 每隔几小时从 GitHub 获取一个已签名的文件;不会发送有关此安装的任何信息。',
|
||||
announcementsLearnMore: '了解更多',
|
||||
announcementsAllUsers: '向所有用户显示',
|
||||
announcementsAllUsersDesc: '关闭:仅管理员可见。身份验证关闭时无效。',
|
||||
localLogin: {
|
||||
disable: '禁用本地用户名/密码登录',
|
||||
disableHint: '启用后,只能通过SSO提供商登录。LDAP不受影响。在服务器上设置 BAMBUDDY_LOCAL_LOGIN=true 可保留恢复通道。',
|
||||
|
||||
@@ -33,6 +33,21 @@ export default {
|
||||
},
|
||||
|
||||
// Common
|
||||
announcements: {
|
||||
title: '公告',
|
||||
unread: '未讀公告:{{count}}',
|
||||
empty: '目前沒有公告。',
|
||||
new: '新',
|
||||
readMore: '了解更多',
|
||||
readMoreCount: '了解更多 (+{{count}})',
|
||||
gotIt: '知道了',
|
||||
source: '來自 Bambuddy 開發者,從 GitHub 取得。可在設定中關閉。',
|
||||
level: {
|
||||
info: '資訊',
|
||||
important: '重要',
|
||||
critical: '緊急',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
plusNMore: '另 {{count}} 個',
|
||||
save: '儲存',
|
||||
@@ -2204,6 +2219,11 @@ export default {
|
||||
checkPrinterFirmware: '檢查印表機韌體',
|
||||
includeBetaUpdates: '包含測試版本',
|
||||
includeBetaUpdatesDesc: '檢查更新時通知測試版和預發布版本',
|
||||
announcementsEnabled: '接收公告',
|
||||
announcementsEnabledDesc: '來自 Bambuddy 開發者的訊息,例如安全性修正和不相容的變更。Bambuddy 每隔幾小時從 GitHub 取得一個已簽署的檔案;不會傳送有關此安裝的任何資訊。',
|
||||
announcementsLearnMore: '了解更多',
|
||||
announcementsAllUsers: '向所有使用者顯示',
|
||||
announcementsAllUsersDesc: '關閉:僅管理員可見。驗證關閉時無效。',
|
||||
localLogin: {
|
||||
disable: '停用本機使用者名稱/密碼登入',
|
||||
disableHint: '啟用後,只能透過SSO提供者登入。LDAP不受影響。在伺服器上設定 BAMBUDDY_LOCAL_LOGIN=true 可保留復原途徑。',
|
||||
|
||||
@@ -1094,6 +1094,9 @@ export function SettingsPage() {
|
||||
// cannot read /settings. Nothing invalidated it, so a currency change
|
||||
// sat behind that query's own staleTime instead of showing up (#3123).
|
||||
queryClient.invalidateQueries({ queryKey: ['ui-flags'] });
|
||||
// Switching announcements off (or to all users) changes who sees the
|
||||
// sidebar entry and the banner; don't wait for the next poll.
|
||||
queryClient.invalidateQueries({ queryKey: ['announcements'] });
|
||||
showToast(t('settings.toast.settingsSaved'), 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -1153,6 +1156,8 @@ export function SettingsPage() {
|
||||
baseline.check_updates !== localSettings.check_updates ||
|
||||
(baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
|
||||
(baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
|
||||
(baseline.announcements_enabled ?? true) !== (localSettings.announcements_enabled ?? true) ||
|
||||
(baseline.announcements_all_users ?? false) !== (localSettings.announcements_all_users ?? false) ||
|
||||
(baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
|
||||
baseline.notification_language !== localSettings.notification_language ||
|
||||
(baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
|
||||
@@ -1269,6 +1274,8 @@ export function SettingsPage() {
|
||||
check_updates: localSettings.check_updates,
|
||||
check_printer_firmware: localSettings.check_printer_firmware,
|
||||
include_beta_updates: localSettings.include_beta_updates,
|
||||
announcements_enabled: localSettings.announcements_enabled ?? true,
|
||||
announcements_all_users: localSettings.announcements_all_users ?? false,
|
||||
local_login_enabled: localSettings.local_login_enabled,
|
||||
notification_language: localSettings.notification_language,
|
||||
bed_cooled_threshold: localSettings.bed_cooled_threshold,
|
||||
@@ -3103,6 +3110,52 @@ export function SettingsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<p className="text-xs font-medium text-bambu-gray uppercase tracking-wider mb-4">{t('announcements.title')}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">{t('settings.announcementsEnabled')}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('settings.announcementsEnabledDesc')}{' '}
|
||||
<a
|
||||
href="https://wiki.bambuddy.cool/features/announcements/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bambu-green hover:underline"
|
||||
>
|
||||
{t('settings.announcementsLearnMore')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.announcements_enabled ?? true}
|
||||
onChange={(e) => updateSetting('announcements_enabled', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className={`flex items-center justify-between ${localSettings.announcements_enabled === false ? 'opacity-50' : ''}`}>
|
||||
<div>
|
||||
<p className="text-white">{t('settings.announcementsAllUsers')}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('settings.announcementsAllUsersDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.announcements_all_users ?? false}
|
||||
onChange={(e) => updateSetting('announcements_all_users', e.target.checked)}
|
||||
disabled={localSettings.announcements_enabled === false}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user