Browse Source

fix(api-keys): expose narrowly-scoped "Update electricity price" toggle (#1356)

  Reporter @maziggy followed the Energy Tracking wiki literally - "create a
  key with Write Settings permission, PATCH /api/v1/settings with
  {energy_cost_per_kwh: ...}" - and hit:
  {"detail":"API keys cannot be used for administrative operations"}.

  Triage showed three independent drifts:
  1. Wiki listed nine fictional API-key permissions (Read Printers / Write
     Settings / Admin / ...) but the UI only ever exposed four toggles
     (Read Status, Manage Queue, Control Printer, Allow Cloud Access).
     There was no Write Settings toggle to tick.
  2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
     every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
     because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
     HA access token. Wider surface than any documented use case needs.
  3. So the wiki had been promising a workflow that was never deliverable.

  Fix: introduce a narrowly-scoped door rather than relax the deny list.

    - New column can_update_energy_cost (default FALSE - existing keys
      never silently gain settings-write capability on upgrade).
    - New route POST /api/v1/settings/electricity-price accepting
      {"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
      wiki already documented so the HA rest_command example needs only a
      URL+method change, not a payload change.
    - Custom dependency require_energy_cost_update() bypasses
      _APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
      flag set. JWT users still go through standard SETTINGS_UPDATE.
    - General PATCH /settings remains denied for API keys - flipping the
      narrow flag does NOT widen general settings-write access. Pinned by
      test_patch_settings_still_denied_with_energy_flag.

  Frontend: fifth "Update electricity price" toggle on the create-API-key
  card + amber "Energy" badge on existing keys with the flag set. Three
  new i18n keys across all 8 locales (German translated, English fallbacks
  elsewhere).
maziggy 1 week ago
parent
commit
ae29a7dcd3

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


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

@@ -64,6 +64,7 @@ async def create_api_key(
         can_control_printer=data.can_control_printer,
         can_read_status=data.can_read_status,
         can_access_cloud=data.can_access_cloud,
+        can_update_energy_cost=data.can_update_energy_cost,
         printer_ids=data.printer_ids,
         expires_at=data.expires_at,
     )
@@ -82,6 +83,7 @@ async def create_api_key(
         can_control_printer=api_key.can_control_printer,
         can_read_status=api_key.can_read_status,
         can_access_cloud=api_key.can_access_cloud,
+        can_update_energy_cost=api_key.can_update_energy_cost,
         printer_ids=api_key.printer_ids,
         enabled=api_key.enabled,
         last_used=api_key.last_used,
@@ -138,6 +140,8 @@ async def update_api_key(
                 detail="can_access_cloud requires the API key to have an owner; recreate the key after upgrading",
             )
         api_key.can_access_cloud = data.can_access_cloud
+    if data.can_update_energy_cost is not None:
+        api_key.can_update_energy_cost = data.can_update_energy_cost
     if data.printer_ids is not None:
         api_key.printer_ids = data.printer_ids
     if data.enabled is not None:

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

@@ -7,10 +7,11 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, UploadFile
 from fastapi.responses import FileResponse, JSONResponse
+from pydantic import BaseModel, Field
 from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -257,6 +258,40 @@ async def patch_settings(
     return await update_settings(settings_update, db, _)
 
 
+class ElectricityPriceUpdate(BaseModel):
+    """Payload for ``POST /settings/electricity-price`` (#1356).
+
+    Mirrors the field name documented in ``wiki/features/energy.md`` so the
+    Home Assistant ``rest_command`` example needs only a URL change, not a
+    payload change. Plain non-negative float; tariffs can go as low as 0.0 in
+    some markets (e.g. free hours).
+    """
+
+    energy_cost_per_kwh: float = Field(ge=0)
+
+
+@router.post("/electricity-price", response_model=AppSettings)
+async def update_electricity_price(
+    payload: ElectricityPriceUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_energy_cost_update()),
+    _is_api_key: bool = Depends(caller_is_api_key),
+):
+    """Update the per-kWh electricity cost used by the energy-tracking pipeline.
+
+    This is the only settings field writable via API key, gated by the
+    ``can_update_energy_cost`` toggle on the key. JWT users still need the
+    standard ``SETTINGS_UPDATE`` permission. See #1356 for the rationale —
+    the general ``PATCH /settings`` route remains denied for API keys because
+    it can rewrite SMTP/LDAP/MQTT credentials, which is a much wider surface
+    than the documented dynamic-tariff use case requires.
+    """
+    await set_setting(db, "energy_cost_per_kwh", str(payload.energy_cost_per_kwh))
+    await db.commit()
+    db.expire_all()
+    return await _build_settings_response(db, is_api_key=_is_api_key)
+
+
 @router.post("/reset", response_model=AppSettings)
 async def reset_settings(
     db: AsyncSession = Depends(get_db),

+ 82 - 0
backend/app/core/auth.py

@@ -60,6 +60,88 @@ def _check_apikey_permissions(perm_strings: list[str]) -> None:
         )
 
 
+def require_energy_cost_update():
+    """Dependency for ``POST /settings/electricity-price`` (#1356).
+
+    Bypasses the ``_APIKEY_DENIED_PERMISSIONS`` ``SETTINGS_UPDATE`` block for
+    API keys that explicitly opt into ``can_update_energy_cost``. Full
+    ``SETTINGS_UPDATE`` for API keys stays denied — this is a narrowly-scoped
+    door for the Home Assistant dynamic-tariff use case documented in
+    ``wiki/features/energy.md``, not a general settings-write capability.
+
+    Accepts:
+      * Auth disabled  → always allowed (matches other settings routes)
+      * JWT user with ``SETTINGS_UPDATE`` permission
+      * API key with ``can_update_energy_cost = True``
+    """
+
+    async def permission_checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return None
+
+            credentials_exception = HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+            # API key path — X-API-Key header or Bearer bb_xxx
+            api_key_value: str | None = None
+            if x_api_key:
+                api_key_value = x_api_key
+            elif credentials is not None and credentials.credentials.startswith("bb_"):
+                api_key_value = credentials.credentials
+
+            if api_key_value is not None:
+                api_key = await _validate_api_key(db, api_key_value)
+                if api_key is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Invalid API key",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                if not api_key.can_update_energy_cost:
+                    raise HTTPException(
+                        status_code=status.HTTP_403_FORBIDDEN,
+                        detail="API key does not have 'update_energy_cost' permission",
+                    )
+                return None
+
+            # JWT path
+            if credentials is None:
+                raise credentials_exception
+
+            try:
+                payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise credentials_exception
+                jti: str | None = payload.get("jti")
+                if not jti or await is_jti_revoked(jti):
+                    raise credentials_exception
+                iat: int | float | None = payload.get("iat")
+            except JWTError:
+                raise credentials_exception
+
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise credentials_exception
+            if not _is_token_fresh(iat, user):
+                raise credentials_exception
+            if not user.has_all_permissions(Permission.SETTINGS_UPDATE.value):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {Permission.SETTINGS_UPDATE.value}",
+                )
+            return user
+
+    return permission_checker
+
+
 # Password hashing
 # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
 # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations

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

@@ -2131,6 +2131,13 @@ async def run_migrations(conn):
         conn,
         "ALTER TABLE api_keys ADD COLUMN can_access_cloud BOOLEAN DEFAULT FALSE",
     )
+    # Narrowly-scoped settings-write toggle for the dynamic-tariff push case
+    # documented in wiki/features/energy.md (#1356). Defaults FALSE so existing
+    # keys never silently gain settings-write capability on upgrade.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_update_energy_cost BOOLEAN DEFAULT FALSE",
+    )
 
     # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
     # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's

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

@@ -31,6 +31,11 @@ class APIKey(Base):
     can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
     can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
     can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
+    # Narrowly-scoped settings write: only POST /settings/electricity-price.
+    # Lets HA/Tibber-style automations push dynamic tariff updates without
+    # granting full SETTINGS_UPDATE (which is denied for API keys because it
+    # could rewrite SMTP/LDAP/MQTT credentials).
+    can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False)
 
     # Optional scope limits
     printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers

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

@@ -11,6 +11,7 @@ class APIKeyCreate(BaseModel):
     can_control_printer: bool = False
     can_read_status: bool = True
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
+    can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
     printer_ids: list[int] | None = None  # null = all printers
     expires_at: datetime | None = None
 
@@ -23,6 +24,7 @@ class APIKeyUpdate(BaseModel):
     can_control_printer: bool | None = None
     can_read_status: bool | None = None
     can_access_cloud: bool | None = None
+    can_update_energy_cost: bool | None = None
     printer_ids: list[int] | None = None
     enabled: bool | None = None
     expires_at: datetime | None = None
@@ -39,6 +41,7 @@ class APIKeyResponse(BaseModel):
     can_control_printer: bool
     can_read_status: bool
     can_access_cloud: bool
+    can_update_energy_cost: bool
     printer_ids: list[int] | None
     enabled: bool
     last_used: datetime | None

+ 208 - 0
backend/tests/integration/test_settings_electricity_price.py

@@ -0,0 +1,208 @@
+"""Integration tests for #1356 — API keys writing electricity price.
+
+The contract these tests pin:
+
+  ``POST /settings/electricity-price`` is the *only* settings field writable
+  via API key, gated by an opt-in ``can_update_energy_cost`` scope. Full
+  ``PATCH /settings`` remains denied for API keys because it can rewrite
+  SMTP/LDAP/MQTT credentials. Two independent fences must pass:
+
+    1. Caller is a JWT user with SETTINGS_UPDATE permission, OR
+    2. Caller is an API key with ``can_update_energy_cost = True``.
+
+  Tests also confirm: (a) API keys without the flag get 403 with a
+  recognizable error, (b) the deny-list for ``PATCH /settings`` still fires
+  for keys that flipped only ``can_update_energy_cost`` on, so flipping the
+  narrow flag doesn't accidentally widen settings-write capability.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+
+async def _setup_auth_with_admin(client: AsyncClient) -> str:
+    """Enable auth + return an admin bearer token. Same pattern as #1182 tests."""
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "energyadmin",
+            "admin_password": "AdminPass1!",  # pragma: allowlist secret
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": "energyadmin", "password": "AdminPass1!"},  # pragma: allowlist secret
+    )
+    return login.json()["access_token"]
+
+
+async def _make_api_key(
+    db: AsyncSession,
+    *,
+    owner_id: int | None,
+    can_update_energy_cost: bool,
+) -> str:
+    full_key, key_hash, key_prefix = generate_api_key()
+    api_key = APIKey(
+        name="energy-tariff",
+        key_hash=key_hash,
+        key_prefix=key_prefix,
+        user_id=owner_id,
+        can_update_energy_cost=can_update_energy_cost,
+    )
+    db.add(api_key)
+    await db.commit()
+    return full_key
+
+
+async def _read_setting(db: AsyncSession, key: str) -> str | None:
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    row = result.scalar_one_or_none()
+    return row.value if row else None
+
+
+class TestCreateAPIKeyWithEnergyScope:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_stamps_energy_flag(self, async_client: AsyncClient):
+        token = await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/api-keys/",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"name": "tariff-push", "can_update_energy_cost": True},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["can_update_energy_cost"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_without_flag_defaults_off(self, async_client: AsyncClient):
+        token = await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/api-keys/",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"name": "no-energy"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["can_update_energy_cost"] is False
+
+
+class TestElectricityPriceEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_with_flag_updates_price(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Happy path: API key with ``can_update_energy_cost=True`` POSTs a new
+        price and the setting persists."""
+        await _setup_auth_with_admin(async_client)
+        result = await db_session.execute(select(User).where(User.username == "energyadmin"))
+        admin = result.scalar_one()
+        full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
+
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            headers={"X-API-Key": full_key},
+            json={"energy_cost_per_kwh": 0.42},
+        )
+        assert resp.status_code == 200, resp.json()
+        # The route returns the full settings response — confirm the new value
+        # is reflected (the rest of the body is the standard scrubbed response).
+        assert resp.json()["energy_cost_per_kwh"] == 0.42
+
+        # Persisted in the settings table.
+        db_session.expire_all()
+        assert await _read_setting(db_session, "energy_cost_per_kwh") == "0.42"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_without_flag_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Default API key (can_update_energy_cost=False) → 403."""
+        await _setup_auth_with_admin(async_client)
+        result = await db_session.execute(select(User).where(User.username == "energyadmin"))
+        admin = result.scalar_one()
+        full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=False)
+
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            headers={"X-API-Key": full_key},
+            json={"energy_cost_per_kwh": 0.42},
+        )
+        assert resp.status_code == 403
+        # Don't pin the exact detail string — just that it identifies the
+        # missing permission. Keeps the test from being noise on copy tweaks.
+        assert "energy" in resp.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_user_with_settings_update_allowed(self, async_client: AsyncClient, db_session: AsyncSession):
+        """JWT user with SETTINGS_UPDATE permission can still hit this route."""
+        token = await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"energy_cost_per_kwh": 0.19},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["energy_cost_per_kwh"] == 0.19
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unauthenticated_rejected(self, async_client: AsyncClient):
+        """No credentials when auth is enabled → 401."""
+        await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            json={"energy_cost_per_kwh": 0.19},
+        )
+        assert resp.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_negative_price_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        """The Pydantic ``ge=0`` constraint catches obviously-wrong values
+        before they reach the settings table — a negative tariff is never
+        valid in any real market."""
+        await _setup_auth_with_admin(async_client)
+        result = await db_session.execute(select(User).where(User.username == "energyadmin"))
+        admin = result.scalar_one()
+        full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
+
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            headers={"X-API-Key": full_key},
+            json={"energy_cost_per_kwh": -0.05},
+        )
+        assert resp.status_code == 422  # FastAPI validation
+
+
+class TestPatchSettingsStillBlocked:
+    """Regression guard: flipping the narrow energy-cost flag must NOT widen
+    full ``PATCH /settings`` access. The general settings-update deny for
+    API keys (which protects SMTP/LDAP/MQTT credentials) stays in place."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_settings_still_denied_with_energy_flag(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        await _setup_auth_with_admin(async_client)
+        result = await db_session.execute(select(User).where(User.username == "energyadmin"))
+        admin = result.scalar_one()
+        full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
+
+        resp = await async_client.patch(
+            "/api/v1/settings/",
+            headers={"X-API-Key": full_key},
+            json={"energy_cost_per_kwh": 0.99},
+        )
+        # Still denied — the wider route uses the deny-list path.
+        assert resp.status_code == 403
+        assert "administrative" in resp.json()["detail"].lower()

+ 111 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -718,6 +718,117 @@ describe('SettingsPage', () => {
     });
   });
 
+  describe('API Keys tab — #1356 energy-cost write scope', () => {
+    /**
+     * The narrowly-scoped settings-write toggle. We pin two contracts here:
+     *
+     *   1. The "Energy" badge renders for keys that have can_update_energy_cost=true.
+     *      Without a visible signal, an operator can't tell which key in their
+     *      list is the one their HA automation depends on.
+     *   2. The create form sends can_update_energy_cost=true to the backend
+     *      when the toggle is checked. The whole point of #1356 is that the
+     *      flag must actually be persisted — a UI that drops it silently
+     *      would put us right back where the bug started.
+     */
+    it('renders the Energy badge for keys with can_update_energy_cost=true', async () => {
+      const keys = [
+        {
+          id: 1,
+          name: 'tariff-pusher',
+          key_prefix: 'bk_tariff01',
+          user_id: 7,
+          can_queue: false,
+          can_control_printer: false,
+          can_read_status: true,
+          can_access_cloud: false,
+          can_update_energy_cost: true,
+          printer_ids: null,
+          enabled: true,
+          last_used: null,
+          created_at: '2026-05-15T00:00:00Z',
+          expires_at: null,
+        },
+      ];
+
+      server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
+
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
+      });
+      const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
+      await user.click(tabButton!);
+
+      await waitFor(() => {
+        expect(screen.getByText('tariff-pusher')).toBeInTheDocument();
+      });
+
+      const row = screen.getByText('tariff-pusher').closest('.flex.items-center.justify-between');
+      expect(row).not.toBeNull();
+      expect(row!.textContent).toContain('Energy');
+    });
+
+    it('passes can_update_energy_cost through to the create call when the toggle is checked', async () => {
+      let posted: { name?: string; can_update_energy_cost?: boolean } | null = null;
+
+      server.use(
+        http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
+        http.post('/api/v1/api-keys/', async ({ request }) => {
+          posted = (await request.json()) as { name?: string; can_update_energy_cost?: boolean };
+          return HttpResponse.json({
+            id: 99,
+            key: 'bk_returnedkey',
+            name: posted.name,
+            key_prefix: 'bk_returne',
+            user_id: 1,
+            can_queue: true,
+            can_control_printer: false,
+            can_read_status: true,
+            can_access_cloud: false,
+            can_update_energy_cost: posted.can_update_energy_cost ?? false,
+            printer_ids: null,
+            enabled: true,
+            last_used: null,
+            created_at: '2026-05-15T00:00:00Z',
+            expires_at: null,
+          });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
+      });
+      const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
+      await user.click(tabButton!);
+
+      const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
+      await user.click(openButton);
+
+      const energyLabelText = await screen.findByText(/Update electricity price/i);
+      const energyLabel = energyLabelText.closest('label');
+      expect(energyLabel).not.toBeNull();
+      const energyCheckbox = energyLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
+      expect(energyCheckbox).not.toBeNull();
+      await user.click(energyCheckbox);
+
+      const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
+      const formSubmit = submitButtons.find(
+        (b) => b.closest('div')?.contains(energyCheckbox) || energyLabel?.parentElement?.parentElement?.contains(b),
+      );
+      await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
+
+      await waitFor(() => {
+        expect(posted).not.toBeNull();
+        expect(posted!.can_update_energy_cost).toBe(true);
+      });
+    });
+  });
+
   describe('external camera snapshot URL override (#1177)', () => {
     /**
      * The snapshot URL input only appears for stream camera types where the

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

@@ -868,6 +868,7 @@ export interface APIKey {
   can_control_printer: boolean;
   can_read_status: boolean;
   can_access_cloud: boolean;
+  can_update_energy_cost: boolean;
   printer_ids: number[] | null;
   enabled: boolean;
   last_used: string | null;
@@ -881,6 +882,7 @@ export interface APIKeyCreate {
   can_control_printer?: boolean;
   can_read_status?: boolean;
   can_access_cloud?: boolean;
+  can_update_energy_cost?: boolean;
   printer_ids?: number[] | null;
   expires_at?: string | null;
 }
@@ -895,6 +897,7 @@ export interface APIKeyUpdate {
   can_control_printer?: boolean;
   can_read_status?: boolean;
   can_access_cloud?: boolean;
+  can_update_energy_cost?: boolean;
   printer_ids?: number[] | null;
   enabled?: boolean;
   expires_at?: string | null;

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

@@ -1738,6 +1738,9 @@ export default {
     cloudAccess: 'Cloud-Zugriff erlauben',
     cloudAccessDescription: 'Liest Bambu-Cloud-Presets und -Filamente in Ihrem Namen. Erfordert eine Anmeldung in Bambu Cloud.',
     cloudBadge: 'Cloud',
+    updateEnergyCost: 'Strompreis aktualisieren',
+    updateEnergyCostDescription: 'Erlaubt diesem Schlüssel, einen neuen Strompreis pro kWh an /settings/electricity-price zu senden. Nützlich für Home-Assistant-Automatisierungen mit dynamischen Tarifen (Tibber, Octopus usw.). Dies ist das einzige Einstellungsfeld, das per API-Schlüssel schreibbar ist.',
+    energyCostBadge: 'Energie',
     legacyKey: 'Alt',
     legacyKeyTooltip: 'Wurde vor der nutzerbezogenen Eigentümerschaft erstellt; neu erstellen, um Cloud-Zugriff zu nutzen',
     unnamedKey: 'Unbenannter Schlüssel',

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

@@ -1741,6 +1741,9 @@ export default {
     cloudAccess: 'Allow cloud access',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudBadge: 'Cloud',
+    updateEnergyCost: 'Update electricity price',
+    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
+    energyCostBadge: 'Energy',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Unnamed Key',

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

@@ -1695,6 +1695,9 @@ export default {
     cloudAccess: 'Allow cloud access',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudBadge: 'Cloud',
+    updateEnergyCost: 'Update electricity price',
+    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
+    energyCostBadge: 'Energy',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Clé sans nom',

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

@@ -1695,6 +1695,9 @@ export default {
     cloudAccess: 'Allow cloud access',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudBadge: 'Cloud',
+    updateEnergyCost: 'Update electricity price',
+    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
+    energyCostBadge: 'Energy',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Chiave senza nome',

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

@@ -1737,6 +1737,9 @@ export default {
     cloudAccess: 'Allow cloud access',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudBadge: 'Cloud',
+    updateEnergyCost: 'Update electricity price',
+    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
+    energyCostBadge: 'Energy',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: '名前なしキー',

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

@@ -1695,6 +1695,9 @@ export default {
     cloudAccess: 'Allow cloud access',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudBadge: 'Cloud',
+    updateEnergyCost: 'Update electricity price',
+    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
+    energyCostBadge: 'Energy',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Chave Sem Nome',

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

@@ -1739,6 +1739,9 @@ export default {
     cloudAccess: 'Allow cloud access',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudBadge: 'Cloud',
+    updateEnergyCost: 'Update electricity price',
+    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
+    energyCostBadge: 'Energy',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: '未命名密钥',

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

@@ -1739,6 +1739,9 @@ export default {
     cloudAccess: 'Allow cloud access',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudBadge: 'Cloud',
+    updateEnergyCost: 'Update electricity price',
+    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
+    energyCostBadge: 'Energy',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: '未命名金鑰',

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

@@ -202,6 +202,7 @@ export function SettingsPage() {
     can_control_printer: false,
     can_read_status: true,
     can_access_cloud: false,
+    can_update_energy_cost: false,
   });
   const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
   const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
@@ -3741,6 +3742,18 @@ export function SettingsPage() {
                           <p className="text-xs text-bambu-gray">{t('settings.cloudAccessDescription', 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.')}</p>
                         </div>
                       </label>
+                      <label className="flex items-center gap-3 cursor-pointer">
+                        <input
+                          type="checkbox"
+                          checked={newAPIKeyPermissions.can_update_energy_cost}
+                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_update_energy_cost: e.target.checked }))}
+                          className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                        />
+                        <div>
+                          <span className="text-white">{t('settings.updateEnergyCost')}</span>
+                          <p className="text-xs text-bambu-gray">{t('settings.updateEnergyCostDescription')}</p>
+                        </div>
+                      </label>
                     </div>
                   </div>
                   <div className="flex items-center gap-2 pt-2">
@@ -3801,6 +3814,9 @@ export function SettingsPage() {
                             {key.can_access_cloud && (
                               <span className="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">{t('settings.cloudBadge', 'Cloud')}</span>
                             )}
+                            {key.can_update_energy_cost && (
+                              <span className="px-1.5 py-0.5 bg-amber-500/20 text-amber-400 rounded">{t('settings.energyCostBadge')}</span>
+                            )}
                             {key.user_id === null && (
                               <span
                                 className="px-1.5 py-0.5 bg-yellow-500/20 text-yellow-400 rounded"

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


+ 1 - 1
static/index.html

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

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