Jelajahi Sumber

Fix per-job queue ETA showing for jobs that cannot start now

    The scheduler only writes waiting_reason on the model-based assignment
    path, so a job pinned to a specific printer sits behind a running print
    with no marker at all. Every such job rendered an identical "starts now"
    ETA that was wrong by the length of everything ahead of it.

    Decide eligibility on the page instead: an item gets an ETA only when its
    printer is idle and it is the item the scheduler would dispatch next,
    following the same ordering the scheduler uses. Staged and future-
    scheduled items do not block the item behind them, matching the
    scheduler, and items conditional on a previous print are excluded.

    The value also froze at first render, since react-query's structural
    sharing keeps the queue reference stable and nothing re-rendered the row.
    formatETA now accepts a base instant and the page drives it from a 30s
    clock shared by every visible row.

    Retire the borrowed printers.estimatedCompletion tooltip for a queue key
    that says what the number means, translated into all 13 locales.
maziggy 1 bulan lalu
induk
melakukan
4ff6377050
39 mengubah file dengan 2262 tambahan dan 52 penghapusan
  1. 61 0
      .env.example
  2. 0 0
      CHANGELOG.md
  3. 6 1
      backend/app/api/routes/auth.py
  4. 16 0
      backend/app/api/routes/mfa.py
  5. 19 0
      backend/app/core/config.py
  6. 8 0
      backend/app/core/database.py
  7. 278 0
      backend/app/core/oidc_env.py
  8. 8 0
      backend/app/main.py
  9. 4 0
      backend/app/models/oidc_provider.py
  10. 3 0
      backend/app/schemas/auth.py
  11. 29 0
      backend/tests/integration/test_local_login_gate.py
  12. 801 0
      backend/tests/integration/test_oidc_env_apply.py
  13. 137 0
      backend/tests/integration/test_oidc_env_lock.py
  14. 43 0
      backend/tests/integration/test_oidc_env_startup.py
  15. 126 0
      backend/tests/unit/test_oidc_env_managed_migration.py
  16. 12 0
      backend/tests/unit/test_oidc_env_provider.py
  17. 248 0
      backend/tests/unit/test_oidc_env_reader.py
  18. 91 0
      frontend/src/__tests__/components/OIDCProviderSettings.test.tsx
  19. 139 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  20. 17 0
      frontend/src/__tests__/utils/date.test.ts
  21. 5 0
      frontend/src/api/client.ts
  22. 58 39
      frontend/src/components/OIDCProviderSettings.tsx
  23. 1 0
      frontend/src/i18n/locales/de.ts
  24. 1 0
      frontend/src/i18n/locales/en.ts
  25. 1 0
      frontend/src/i18n/locales/es.ts
  26. 1 0
      frontend/src/i18n/locales/fr.ts
  27. 1 0
      frontend/src/i18n/locales/it.ts
  28. 1 0
      frontend/src/i18n/locales/ja.ts
  29. 1 0
      frontend/src/i18n/locales/ko.ts
  30. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  31. 1 0
      frontend/src/i18n/locales/ru.ts
  32. 1 0
      frontend/src/i18n/locales/tr.ts
  33. 1 0
      frontend/src/i18n/locales/uk.ts
  34. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  35. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  36. 131 9
      frontend/src/pages/QueuePage.tsx
  37. 8 2
      frontend/src/utils/date.ts
  38. 0 0
      static/assets/index-DPZgvI9N.js
  39. 1 1
      static/index.html

+ 61 - 0
.env.example

@@ -66,3 +66,64 @@ LOG_TO_FILE=true
 # LDAP is governed by its own ldap_enabled toggle and is not affected.
 # Leave unset for normal operation.
 # BAMBUDDY_LOCAL_LOGIN=true
+
+# --- OIDC provider from the environment (#2593) ------------------------------
+# Defines ONE OIDC provider declaratively, for deployments that are managed by
+# compose files or GitOps and never touch the settings UI. Providers created in
+# the UI are unaffected and keep working alongside this one.
+#
+# Activates only when all four required vars below are set; an empty value
+# counts as unset. The provider is written on startup and re-applied on every
+# boot, so the UI shows it as read-only and the API refuses to change it -- an
+# edit there would be reverted at the next restart anyway.
+#
+# Removing the vars DISABLES the provider rather than deleting it: accounts
+# linked to it would otherwise lose their link permanently. Re-adding the vars
+# enables it again with those links intact.
+#
+# If you lock yourself out, BAMBUDDY_LOCAL_LOGIN=true above is the way back in.
+#
+# Required:
+# BAMBUDDY_OIDC_NAME=Keycloak
+# BAMBUDDY_OIDC_ISSUER_URL=https://sso.example.com/realms/main
+# BAMBUDDY_OIDC_CLIENT_ID=bambuddy
+# BAMBUDDY_OIDC_CLIENT_SECRET=your-client-secret
+#
+# Optional, shown with their defaults:
+# BAMBUDDY_OIDC_SCOPES=openid email profile
+# BAMBUDDY_OIDC_ENABLED=true
+# BAMBUDDY_OIDC_AUTO_CREATE_USERS=false
+# BAMBUDDY_OIDC_AUTO_LINK_EXISTING=false
+# BAMBUDDY_OIDC_EMAIL_CLAIM=email
+# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=true
+# BAMBUDDY_OIDC_ICON_URL=
+# BAMBUDDY_OIDC_AUTOLOGIN=false
+# BAMBUDDY_OIDC_DEFAULT_GROUP=
+#
+# Booleans accept true/1/yes or false/0/no (case-insensitive). Blank or unset
+# uses the default; any other value is rejected and the provider is skipped.
+#
+# DEFAULT_GROUP is the group new users land in when AUTO_CREATE_USERS is on;
+# without it they get Viewers. It matches a group NAME exactly (case-sensitive)
+# -- group ids are assigned per install, so the same compose file would point at
+# a different group on every deployment. A name that matches no group is
+# refused: the provider is left as it was and the reason is logged, rather than
+# quietly creating under-privileged users the locked UI could not correct. On a
+# FIRST boot that means no provider is created at all and no SSO button appears
+# -- create the group first. Removing the variable clears the group again.
+#
+# AUTO_LINK_EXISTING binds an OIDC identity to an existing local account with
+# the same email address. With EMAIL_CLAIM=email it is refused unless
+# REQUIRE_EMAIL_VERIFIED=true, because an identity provider that does not
+# verify addresses would let anyone claim someone else's account. The whole
+# config is then skipped and logged; the app still starts.
+#
+# ISSUER_URL must be https:// and publicly reachable -- private, loopback,
+# link-local, numeric-encoded and IPv4-mapped hosts are rejected. An in-cluster
+# URL like http://keycloak:8080 is refused with a single log line and no SSO
+# button; use the externally-reachable HTTPS issuer URL instead.
+#
+# NAME is matched against the existing providers on every boot: setting it to
+# the name of one you already created in the UI ADOPTS and OVERWRITES it (its
+# issuer, client id and secret are replaced and it becomes read-only). Pick a
+# name that doesn't collide unless that takeover is intended.

File diff ditekan karena terlalu besar
+ 0 - 0
CHANGELOG.md


+ 6 - 1
backend/app/api/routes/auth.py

@@ -35,6 +35,7 @@ from backend.app.core.auth import (
     security,
 )
 from backend.app.core.database import async_session, get_db
+from backend.app.core.oidc_env import env_bool
 from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
@@ -122,7 +123,11 @@ def _local_login_env_bypass() -> bool:
     an install whose SSO provider is unreachable. Accepted truthy values:
     ``true``, ``1``, ``yes`` (case-insensitive).
     """
-    return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
+    # strict=False: this runs on the login/forgot-password request path, not at
+    # startup. An unrecognized value must fall back to "off" (the safe default),
+    # never raise -- a 500 on the recovery endpoint is the opposite of what this
+    # bypass is for.
+    return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False)
 
 
 def _get_client_ip(request: Request) -> str:

+ 16 - 0
backend/app/api/routes/mfa.py

@@ -1404,6 +1404,18 @@ async def create_oidc_provider(
     return _build_provider_response(provider)
 
 
+def _refuse_if_env_managed(provider: OIDCProvider) -> None:
+    """Startup rewrites this provider from BAMBUDDY_OIDC_* on every boot, so an
+    edit here would be accepted and then silently reverted at the next restart.
+    BAMBUDDY_LOCAL_LOGIN (#1589) remains the recovery path if it becomes
+    unusable, so refusing outright cannot lock anyone out."""
+    if provider.is_env_managed:
+        raise HTTPException(
+            status_code=status.HTTP_409_CONFLICT,
+            detail="This OIDC provider is managed by environment variables and cannot be modified.",
+        )
+
+
 @router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
 async def update_oidc_provider(
     provider_id: int,
@@ -1426,6 +1438,7 @@ async def update_oidc_provider(
     provider = result2.scalar_one_or_none()
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
     if body.default_group_id is not None:
         grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
@@ -1503,6 +1516,7 @@ async def delete_oidc_provider(
     provider = result2.scalar_one_or_none()
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
     await db.delete(provider)
     await db.commit()
@@ -1571,6 +1585,7 @@ async def delete_oidc_provider_icon(
     provider = result.scalar_one_or_none()
     if provider is None:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
     # Setting deferred columns is safe — no read happens, just a write.
     provider.icon_url = None
@@ -1603,6 +1618,7 @@ async def refresh_oidc_provider_icon(
     provider = result.scalar_one_or_none()
     if provider is None:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
     if not provider.icon_url:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,

+ 19 - 0
backend/app/core/config.py

@@ -135,6 +135,25 @@ _INTENTIONAL_UNSETTINGS = {
     "LOG_DIR",  # config.py (above)
     "LOG_LEVEL",  # main.py logging setup
     "BUG_REPORT_RELAY_URL",  # config.py (above)
+    # #1589 — api/routes/auth.py reads this on the login path. Unregistered it
+    # logged "possible typo" at every boot, telling an operator who is locked
+    # out and following the documented recovery that the variable is not real.
+    "BAMBUDDY_LOCAL_LOGIN",
+    # #2593 — core/oidc_env.py reads these directly; they are not Settings
+    # fields because they map to an OIDCProvider row, not to app config.
+    "BAMBUDDY_OIDC_NAME",
+    "BAMBUDDY_OIDC_ISSUER_URL",
+    "BAMBUDDY_OIDC_CLIENT_ID",
+    "BAMBUDDY_OIDC_CLIENT_SECRET",
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
 }
 
 _known_settings_fields = {f.upper() for f in settings.model_fields}

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

@@ -3808,6 +3808,14 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
 
+    # Migration: Add is_env_managed column to oidc_providers (#2593). Marks the
+    # provider upserted from BAMBUDDY_OIDC_* env vars on startup. Postgres
+    # rejects ``DEFAULT 0`` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT false")
+
     # Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
     # the start-watchdog reverted the row from 'printing' back to 'pending' so a
     # printer that never actually starts stops being retried forever. INTEGER

+ 278 - 0
backend/app/core/oidc_env.py

@@ -0,0 +1,278 @@
+"""Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593).
+
+A declarative deployment (compose, Helm, GitOps) has no way to click through
+the settings UI, so one provider can be configured entirely from the
+environment. This module only reads and defaults; validity is decided by the
+same OIDCProviderCreate schema the API uses, so env config cannot bypass a
+check the UI enforces.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import os
+
+from pydantic import ValidationError
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+logger = logging.getLogger(__name__)
+
+# All four or nothing: a provider missing its secret would be written to the
+# database and then fail at authorize time, long after the operator could
+# connect the failure to a typo in their compose file.
+_REQUIRED = (
+    "BAMBUDDY_OIDC_NAME",
+    "BAMBUDDY_OIDC_ISSUER_URL",
+    "BAMBUDDY_OIDC_CLIENT_ID",
+    "BAMBUDDY_OIDC_CLIENT_SECRET",
+)
+
+_TRUTHY = {"true", "1", "yes"}
+_FALSY = {"false", "0", "no"}
+
+
+class EnvOIDCConfigError(Exception):
+    """A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
+    boolean variable's name and value -- booleans are not secret, so the message
+    is safe to log in full (unlike client_secret, which never reaches here)."""
+
+
+def env_bool(key: str, default: bool, *, strict: bool = True) -> bool:
+    """Parse a boolean env var. Absent or blank -> default (empty == unset).
+
+    strict (the default): an unrecognized non-empty value raises
+    EnvOIDCConfigError, so a typo is refused loudly rather than silently read as
+    the wrong thing. strict=False: an unrecognized value falls back to the
+    default instead -- for a caller on a request path where a raise would be a
+    500, not a skipped startup config (see _local_login_env_bypass).
+    """
+    value = os.environ.get(key)
+    if value is None or value.strip() == "":
+        return default  # absent or blank == unset -> default, per the module's promise
+    norm = value.strip().lower()
+    if norm in _TRUTHY:
+        return True
+    if norm in _FALSY:
+        return False
+    if strict:
+        raise EnvOIDCConfigError(f"{key}={value!r} is not a recognized boolean (use true/1/yes or false/0/no)")
+    return default
+
+
+def read_env_oidc_config() -> dict | None:
+    """The provider's fields from the environment, or None if it isn't configured.
+
+    An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in
+    a compose file is a forgotten value, not an intentional empty secret. Blank
+    means blank *after* stripping, and the surviving value is stripped too: a
+    Kubernetes Secret written as a block scalar (``stringData: secret: |``) or
+    created from a file carries a trailing newline that nothing downstream
+    rejects -- max_length is the only bound the schema puts on these four. An
+    issuer_url with a trailing newline is stored and enabled, and then fails
+    with httpx.InvalidURL on the first click of the SSO button, which is the
+    authorize-time failure the all-or-nothing rule above exists to prevent.
+    """
+    required = {key: (os.environ.get(key) or "").strip() for key in _REQUIRED}
+    if not all(required.values()):
+        return None
+
+    return {
+        "name": required["BAMBUDDY_OIDC_NAME"],
+        "issuer_url": required["BAMBUDDY_OIDC_ISSUER_URL"],
+        "client_id": required["BAMBUDDY_OIDC_CLIENT_ID"],
+        "client_secret": required["BAMBUDDY_OIDC_CLIENT_SECRET"],
+        "scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "openid email profile",
+        "is_enabled": env_bool("BAMBUDDY_OIDC_ENABLED", True),
+        "auto_create_users": env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),
+        "auto_link_existing_accounts": env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
+        "email_claim": (os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM") or "").strip() or "email",
+        "require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
+        "icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
+        "is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
+        # A name, not an id: ids are assigned per install, so the same compose
+        # file would point at a different group on every deployment. Resolved
+        # against the database in apply_env_oidc_provider -- the reader has no
+        # session and stays dumb.
+        "default_group": (os.environ.get("BAMBUDDY_OIDC_DEFAULT_GROUP") or "").strip() or None,
+    }
+
+
+# Everything the schema validates and the model stores, except client_secret --
+# that one goes through the property so it is encrypted at rest.
+_APPLIED_FIELDS = (
+    "name",
+    "issuer_url",
+    "client_id",
+    "scopes",
+    "is_enabled",
+    "auto_create_users",
+    "auto_link_existing_accounts",
+    "email_claim",
+    "require_email_verified",
+    "icon_url",
+    "is_autologin",
+    # Written on every boot, so a group that is no longer declared is cleared:
+    # the environment is the whole truth for this row, and the API lock means
+    # a lingering value could not be removed in the UI either.
+    "default_group_id",
+)
+
+
+async def apply_env_oidc_provider(db: AsyncSession) -> None:
+    """Upsert the env-managed provider, or release it when the config is gone.
+
+    Never raises: this runs during startup, and a typo in one variable -- or a
+    DB error on commit -- must not stop the app from booting. A rejected
+    config is logged and skipped.
+    """
+    try:
+        await _apply_env_oidc_provider(db)
+    except Exception as exc:  # noqa: BLE001 -- startup must survive any failure here
+        # Never str(exc): a DB error message can echo a configured value. Class only.
+        logger.error("BAMBUDDY_OIDC_* could not be applied: %s", type(exc).__name__)
+        # A commit may have half-applied; roll back so the shared session is
+        # left clean for the rest of startup. Suppressed because rollback on a
+        # wedged connection can itself raise -- and the whole point here is that
+        # nothing in this path takes the boot down. The session is discarded by
+        # the caller's `async with` regardless.
+        with contextlib.suppress(Exception):
+            await db.rollback()
+
+
+async def _apply_env_oidc_provider(db: AsyncSession) -> None:
+    # Imported here rather than at module scope: app.core is imported by the
+    # models themselves, so a top-level import would be a cycle.
+    from backend.app.models.group import Group
+    from backend.app.models.oidc_provider import OIDCProvider
+    from backend.app.schemas.auth import OIDCProviderCreate
+
+    try:
+        config = read_env_oidc_config()
+    except EnvOIDCConfigError as exc:
+        # Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
+        # log clearly and leave any running provider as it was. Safe to log the
+        # full message -- EnvOIDCConfigError only ever carries a boolean var.
+        logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
+        return
+
+    if config is None:
+        # Nothing to look up by name any more, so the previously managed rows are
+        # found by the flag -- and then released. All of them: the upsert's sweep
+        # should keep that at one, but scalar_one_or_none() would raise
+        # MultipleResultsFound out of the lifespan the moment it isn't, and
+        # losing the boot is too steep a price for an invariant check.
+        released_rows = (
+            (await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))).scalars().all()
+        )
+        for released in released_rows:
+            # Disabled, never deleted: user_oidc_links.provider_id is FK ON
+            # DELETE CASCADE, so removing the row would unlink every bound
+            # account and the links would not come back when the variables do.
+            # The flag is cleared as well: with no config behind it, a provider
+            # the API still refuses to edit or delete would be a dead end
+            # reachable only through the database.
+            released.is_enabled = False
+            released.is_env_managed = False
+            # Cleared too, or the released row keeps a latent autologin claim:
+            # update_oidc_provider only re-runs the exclusivity sweep when a
+            # request sets is_autologin=True, so re-enabling this row in the UI
+            # would silently make it the autologin target again.
+            released.is_autologin = False
+            logger.info(
+                "BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
+                released.name,
+            )
+        if released_rows:
+            await db.commit()
+        return
+
+    # Identity is the name, which is unique on the table. Matching on the flag
+    # instead meant an operator who named the env provider after one that
+    # already existed hit that unique constraint during startup -- and this
+    # function runs in the lifespan, so the app would not boot.
+    existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
+
+    # Resolved before anything is written, so a name that matches no group
+    # leaves the running provider untouched. Refused rather than defaulted:
+    # falling back would put every auto-created user in Viewers (routes/mfa.py)
+    # for as long as the typo lives, and the API answers 422 for a
+    # default_group_id that does not exist -- env config gets the same answer.
+    group_name = config.pop("default_group", None)
+    if group_name is not None:
+        group = (await db.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
+        if group is None:
+            # Spelled out because the two cases differ sharply: an existing
+            # provider keeps running on its last good config, while on a first
+            # boot nothing is created at all and the login page has no SSO
+            # button until the name matches.
+            logger.error(
+                "BAMBUDDY_OIDC_DEFAULT_GROUP=%r matches no group, provider not applied (%s).",
+                group_name,
+                "previous config left running" if existing is not None else "no provider created",
+            )
+            return
+        config["default_group_id"] = group.id
+
+    try:
+        # The same schema the API uses, so env config cannot reach a state the
+        # UI would have refused (notably the SEC-1 auto-link check).
+        validated = OIDCProviderCreate(**config)
+    except ValidationError as exc:
+        # errors(include_input=False) strips the submitted values -- str(exc)
+        # embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
+        logger.error(
+            "BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
+            exc.errors(include_input=False),
+        )
+        return
+    except Exception as exc:  # noqa: BLE001 -- any rejection must be survivable
+        # Log only the exception class, never str(exc): an unexpected error here
+        # could carry a configured value in its message. Structural guarantee,
+        # not one contingent on which exceptions the schema validators raise.
+        logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
+        return
+
+    # Computed before `existing` is reassigned below: a freshly-created row is
+    # not an adoption, and a found row that was already env-managed is a
+    # routine re-apply -- only a found row that the UI created is an adoption.
+    adopted_ui_provider = existing is not None and not existing.is_env_managed
+
+    if existing is None:
+        existing = OIDCProvider(is_env_managed=True)
+        db.add(existing)
+    for field in _APPLIED_FIELDS:
+        setattr(existing, field, getattr(validated, field))
+    existing.client_secret = validated.client_secret
+    existing.is_env_managed = True
+    await db.flush()  # the id is needed by the sweeps below
+
+    # Renaming BAMBUDDY_OIDC_NAME matches nothing, so the row managed until now
+    # stays behind. Left flagged it would keep a stale issuer and secret on the
+    # login page while the API refuses every edit, disable and delete on it
+    # (409) -- the dead end reachable only through the database that the release
+    # path exists to prevent -- and the next release would find two rows and
+    # take the boot down with MultipleResultsFound. Released, not deleted, for
+    # the same cascade reason as everywhere else.
+    await db.execute(
+        update(OIDCProvider)
+        .where(OIDCProvider.id != existing.id, OIDCProvider.is_env_managed.is_(True))
+        .values(is_env_managed=False, is_enabled=False, is_autologin=False)
+    )
+
+    if existing.is_autologin:
+        await db.execute(
+            update(OIDCProvider)
+            .where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
+            .values(is_autologin=False)
+        )
+    await db.commit()
+    if adopted_ui_provider:
+        logger.warning(
+            "Env-managed OIDC provider %r adopted an existing UI-created provider of the "
+            "same name; its issuer, client and secret are now managed by BAMBUDDY_OIDC_*.",
+            existing.name,
+        )
+    else:
+        logger.info("Env-managed OIDC provider %r applied.", existing.name)

+ 8 - 0
backend/app/main.py

@@ -6995,6 +6995,14 @@ async def lifespan(app: FastAPI):
 
     await init_db()
 
+    # After migrations, so the is_env_managed column exists. Never raises --
+    # a bad BAMBUDDY_OIDC_* value is logged and skipped rather than blocking
+    # startup (see apply_env_oidc_provider).
+    from backend.app.core.oidc_env import apply_env_oidc_provider
+
+    async with async_session() as oidc_db:
+        await apply_env_oidc_provider(oidc_db)
+
     # Register an app-scoped httpx client for Bambu Cloud services so
     # per-request BambuCloudService instances reuse the same connection pool
     # (important for routes like /cloud/filament-info that chain many

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

@@ -128,6 +128,10 @@ class OIDCProvider(Base):
     # authorize-URL fetch fails or times out, and ``/login?fallback=local``
     # plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
     is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    # Marks the single provider defined by BAMBUDDY_OIDC_* env vars. Upserted on
+    # startup; UI/API writes to it are rejected. Never delete-recreated (user_oidc_links
+    # FK is ON DELETE CASCADE).
+    is_env_managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
 
     @property
     def has_icon(self) -> bool:

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

@@ -527,6 +527,9 @@ class OIDCProviderResponse(BaseModel):
     icon_url: str | None = None
     default_group_id: int | None = None
     is_autologin: bool = False  # #1589
+    # #2593 — the UI renders this provider read-only; without the flag it would
+    # offer editable fields whose writes the API then refuses with 409.
+    is_env_managed: bool = False
     # Set explicitly in the route handler from `icon_content_type is not None`
     # rather than `@computed_field` (project policy) or `icon_data is not None`
     # (would trigger an async lazy-load on the deferred BLOB column).

+ 29 - 0
backend/tests/integration/test_local_login_gate.py

@@ -96,6 +96,35 @@ class TestLocalLoginGate:
         )
         assert response.status_code == 200, response.text
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unrecognized_env_value_does_not_500_the_login_path(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """The recovery bypass reads BAMBUDDY_LOCAL_LOGIN on the request path, so
+        an unrecognized value (BAMBUDDY_LOCAL_LOGIN=on) must fall back to "off",
+        never raise -- env_bool is strict for the startup OIDC reader but lenient
+        here. A raise would 500 the very endpoint the bypass exists to keep open."""
+        await _enable_auth(async_client, "gateonval")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "on")
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gateonval", "password": "GatePass1!"},
+        )
+        # Bypass stays off (same 401 as no env var), and crucially not a 500.
+        assert response.status_code == 401, response.text
+
+    def test_the_bypass_var_is_registered_in_the_typo_guard(self):
+        """config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
+        Unregistered, this one tells an operator who is locked out and following
+        the documented recovery that the variable they just set is not real --
+        while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
+        from backend.app.core.config import _INTENTIONAL_UNSETTINGS
+
+        assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_forgot_password_rejected_when_local_disabled(

+ 801 - 0
backend/tests/integration/test_oidc_env_apply.py

@@ -0,0 +1,801 @@
+"""Upserting the env-managed OIDC provider (#2593).
+
+Startup applies BAMBUDDY_OIDC_* to the database. The row is updated in place,
+never delete-recreated: user_oidc_links.provider_id is FK ON DELETE CASCADE, so
+recreating the provider would silently unlink every account bound to it.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.core.oidc_env import apply_env_oidc_provider
+from backend.app.models.oidc_provider import OIDCProvider
+
+REQUIRED = {
+    "BAMBUDDY_OIDC_NAME": "Keycloak",
+    "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
+    "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
+    "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
+}
+
+ALL_VARS = (
+    *REQUIRED,
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_env(monkeypatch):
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+
+
+def _configure(monkeypatch, **overrides):
+    for key, value in REQUIRED.items():
+        monkeypatch.setenv(key, value)
+    for key, value in overrides.items():
+        monkeypatch.setenv(key, value)
+
+
+async def _env_provider(db_session) -> OIDCProvider | None:
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    return result.scalar_one_or_none()
+
+
+@pytest.mark.asyncio
+async def test_creates_the_provider_from_env(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.name == "Keycloak"
+    assert provider.client_id == "bambuddy"
+    assert provider.is_env_managed is True
+    assert provider.client_secret == "s3cr3t"  # property decrypts
+
+
+@pytest.mark.asyncio
+async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
+    """The id must survive: user_oidc_links references it with ON DELETE
+    CASCADE, so a delete-recreate would unlink every bound account."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.client_id == "rotated"
+
+
+@pytest.mark.asyncio
+async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    # Looked up by name, not by the flag: releasing the provider clears the flag,
+    # and the point of this test is that the ROW survives either way.
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    provider = result.scalar_one_or_none()
+    assert provider is not None, "deleting would cascade away every account link"
+    assert provider.id == original_id
+    assert provider.is_enabled is False
+
+
+@pytest.mark.asyncio
+async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
+    """Only one provider may be the autologin target; the env one wins."""
+    ui_provider = OIDCProvider(
+        name="UI provider",
+        issuer_url="https://other.example.com",
+        client_id="ui",
+        is_autologin=True,
+    )
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+
+    await db_session.refresh(ui_provider)
+    assert (await _env_provider(db_session)).is_autologin is True
+    assert ui_provider.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
+    ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    await db_session.refresh(ui_provider)
+    assert ui_provider.is_env_managed is False
+    assert ui_provider.is_enabled is True
+    assert ui_provider.client_id == "ui"
+
+
+@pytest.mark.asyncio
+async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
+    """auto-link + unverified email is the SEC-1 account-takeover shape. The
+    schema rejects it for the UI, and env config must not be a way around that
+    -- but a bad variable must not stop the app from booting either."""
+    _configure(
+        monkeypatch,
+        BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
+        BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
+    )
+
+    await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None
+
+
+@pytest.mark.asyncio
+async def test_a_rejected_config_never_logs_the_client_secret(db_session, monkeypatch, caplog):
+    """client_secret has max_length=512, so an over-long value raises
+    string_too_long. The rejection must be logged without the value: str(exc)
+    embeds input_value=..., which would leak the secret (no-secrets-in-logs)."""
+    secret = "S3CR3T" * 100  # > 512 chars -> ValidationError on client_secret
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET=secret)
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None  # rejected, not booted-through
+    assert "rejected" in caplog.text  # the rejection was actually logged
+    assert secret not in caplog.text
+    assert "S3CR3T" not in caplog.text  # not even a fragment of the value
+
+
+# --- an unrecognized boolean is rejected, not guessed --------------------------
+# `_env_bool` used to return the default for anything outside {true,1,yes}, so
+# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
+# BAMBUDDY_OIDC_ENABLED=on silently disabled the provider. Strict parsing
+# refuses the config instead -- through the same clean path a bad
+# DEFAULT_GROUP or a ValidationError already uses, so a typo never releases a
+# provider that was running fine.
+
+
+@pytest.mark.asyncio
+async def test_an_unrecognized_require_email_verified_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original = await _env_provider(db_session)
+    original_id, original_enabled = original.id, original.is_enabled
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "on")
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a typo must not release the provider"
+    assert provider.id == original_id
+    assert provider.is_enabled == original_enabled
+    assert provider.is_env_managed is True
+    assert "rejected" in caplog.text
+    assert "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_an_unrecognized_enabled_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original = await _env_provider(db_session)
+    original_id, original_enabled = original.id, original.is_enabled
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_ENABLED", "on")
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a typo must not release the provider"
+    assert provider.id == original_id
+    assert provider.is_enabled == original_enabled
+    assert provider.is_env_managed is True
+    assert "rejected" in caplog.text
+    assert "BAMBUDDY_OIDC_ENABLED" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_a_non_validation_error_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
+    """The generic except branch handles anything that isn't a ValidationError
+    (e.g. a library call raising mid-construction). It must not stop boot and,
+    since such a message could carry a configured value, must log only the
+    exception class -- never str(exc)."""
+    # oidc_env imports OIDCProviderCreate inside the function (to avoid an
+    # import cycle), so patch it at its source module, not on oidc_env.
+    import backend.app.schemas.auth as auth_schemas
+
+    def _raise(**_kwargs):
+        raise RuntimeError("boom leaked-secret")
+
+    monkeypatch.setattr(auth_schemas, "OIDCProviderCreate", _raise)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert await _env_provider(db_session) is None
+    assert "could not be applied" in caplog.text
+    assert "RuntimeError" in caplog.text  # class is logged...
+    assert "leaked-secret" not in caplog.text  # ...but nothing from the message
+
+
+@pytest.mark.asyncio
+async def test_a_commit_failure_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
+    """The upsert's db.execute/db.commit calls sit outside the inner
+    ValidationError guard -- a Postgres blip or a SQLite WAL lock at startup
+    must not propagate out of the lifespan either. Only the exception class
+    may be logged, never str(exc), since a DB error message can echo a
+    configured value."""
+
+    async def _raise_on_commit():
+        raise RuntimeError("database is locked")
+
+    monkeypatch.setattr(db_session, "commit", _raise_on_commit)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert "could not be applied" in caplog.text
+    assert "RuntimeError" in caplog.text  # class is logged...
+    assert "leaked-secret" not in caplog.text  # ...but nothing from the message
+
+
+@pytest.mark.asyncio
+async def test_a_failing_rollback_is_also_survivable(db_session, monkeypatch, caplog):
+    """The handler rolls back after a failed commit -- but rollback on a wedged
+    connection can raise too, and 'never raises' has to hold for that as well
+    or the boot dies on the recovery path. The rollback is suppressed."""
+
+    async def _raise_on_commit():
+        raise RuntimeError("database is locked")
+
+    async def _raise_on_rollback():
+        raise RuntimeError("connection is closed")
+
+    monkeypatch.setattr(db_session, "commit", _raise_on_commit)
+    monkeypatch.setattr(db_session, "rollback", _raise_on_rollback)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise, even here
+
+    assert "could not be applied" in caplog.text
+    assert "leaked-secret" not in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
+    """Every boot re-applies; the second run must not create a second row."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    await apply_env_oidc_provider(db_session)
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    assert len(result.scalars().all()) == 1
+
+
+# --- identity is the name, not the flag ---------------------------------------
+# The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
+# Matching on is_env_managed instead made three things impossible: adopting a
+# provider that already carries the name (the insert hit the unique constraint
+# and took startup down with it), releasing the provider when the config goes
+# away, and finding it again afterwards.
+
+
+@pytest.mark.asyncio
+async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
+    """An operator who names the env provider after one they created in the UI
+    must not end up with an app that refuses to boot."""
+    ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+    original_id = ui_provider.id
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.id == original_id, "adopted, not duplicated"
+    assert provider.client_id == "bambuddy"
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    assert len(result.scalars().all()) == 1
+
+
+@pytest.mark.asyncio
+async def test_adopting_a_ui_provider_logs_a_distinct_warning(db_session, monkeypatch, caplog):
+    """Overwriting a UI-created provider in place is a bigger deal than a
+    routine re-apply -- it must not be silent at the same INFO level."""
+    ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch)
+    with caplog.at_level(logging.INFO):
+        await apply_env_oidc_provider(db_session)
+
+    warnings = [r for r in caplog.records if r.levelname == "WARNING"]
+    assert any("adopted" in r.message for r in warnings)
+
+
+@pytest.mark.asyncio
+async def test_a_routine_reapply_does_not_log_an_adoption_warning(db_session, monkeypatch, caplog):
+    """The same provider re-applying on the next boot is not an adoption --
+    it was already env-managed."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    caplog.clear()
+
+    with caplog.at_level(logging.INFO):
+        await apply_env_oidc_provider(db_session)
+
+    warnings = [r for r in caplog.records if r.levelname == "WARNING"]
+    assert not any("adopted" in r.message for r in warnings)
+
+
+@pytest.mark.asyncio
+async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
+    """Nothing manages it any more, so the API must stop refusing edits and
+    deletes -- otherwise the row is a dead end only reachable via the database."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    provider = result.scalar_one()
+    assert provider.is_enabled is False
+    assert provider.is_env_managed is False
+
+
+@pytest.mark.asyncio
+async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
+    """The account links hang off this row; a second provider would orphan them."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.is_enabled is True
+
+
+@pytest.mark.asyncio
+async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
+    monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.issuer_url == "https://sso.example.com/realms/other"
+    assert provider.client_id == "rotated"
+
+
+# --- a rename must not leave the old row managed -------------------------------
+# Identity is the name, so renaming BAMBUDDY_OIDC_NAME matches nothing and
+# creates a second row. Leaving the flag on the first one is what makes that
+# fatal: it stays enabled with a stale issuer and secret on the login page, the
+# API refuses every edit/disable/delete on it (409), and the release path's
+# scalar_one_or_none() then raises MultipleResultsFound out of the lifespan --
+# the app stops booting. Both states are reachable by ordinary config edits.
+
+
+async def _env_managed(db_session) -> list[OIDCProvider]:
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    return list(result.scalars().all())
+
+
+@pytest.mark.asyncio
+async def test_renaming_the_provider_releases_the_row_it_managed_before(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    managed = await _env_managed(db_session)
+    assert [p.name for p in managed] == ["Authentik"], "exactly one row may carry the flag"
+
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    # Released, not deleted -- user_oidc_links.provider_id cascades.
+    assert old.is_env_managed is False
+    assert old.is_enabled is False, "a stale issuer must not stay on the login page"
+    assert old.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_boot_survives_removing_the_config_after_a_rename(db_session, monkeypatch):
+    """The MultipleResultsFound path: rename, then unset. Must not raise."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert await _env_managed(db_session) == []
+    names = (await db_session.execute(select(OIDCProvider.name))).scalars().all()
+    assert sorted(names) == ["Authentik", "Keycloak"], "both rows survive, both released"
+
+
+@pytest.mark.asyncio
+async def test_every_managed_row_is_released_not_just_one(db_session, monkeypatch):
+    """The upsert's sweep should keep this at one row. Should is not enforced by
+    the schema, and the cost of being wrong is the whole release path raising
+    MultipleResultsFound out of the lifespan -- so it releases what it finds."""
+    for name in ("Keycloak", "Authentik"):
+        stale = OIDCProvider(
+            name=name,
+            issuer_url="https://sso.example.com/realms/main",
+            client_id="bambuddy",
+            is_env_managed=True,
+        )
+        stale.client_secret = "s3cr3t"
+        db_session.add(stale)
+    await db_session.commit()
+
+    await apply_env_oidc_provider(db_session)  # no vars set -> release path
+
+    assert await _env_managed(db_session) == []
+
+
+@pytest.mark.asyncio
+async def test_releasing_the_provider_clears_autologin(db_session, monkeypatch):
+    """is_enabled and is_env_managed alone leave a UI-editable row carrying a
+    latent autologin claim: update_oidc_provider only runs the exclusivity
+    sweep when a request sets is_autologin=True, so merely re-enabling this row
+    makes it the autologin target again."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    assert (await _env_provider(db_session)).is_autologin is True
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    released = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))).scalar_one()
+    assert released.is_autologin is False
+
+
+# --- default group by name -----------------------------------------------------
+# Group ids are not stable across installs, so a declarative deployment cannot
+# name one by id. Without this, every auto-created user falls back to Viewers
+# (routes/mfa.py) and the env lock means the UI cannot correct the provider.
+
+
+async def _group(db_session, name: str):
+    from backend.app.models.group import Group
+
+    group = Group(name=name, description=f"Test group {name}")
+    db_session.add(group)
+    await db_session.commit()
+    return group
+
+
+@pytest.mark.asyncio
+async def test_the_default_group_is_resolved_by_name(db_session, monkeypatch):
+    group = await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id == group.id
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_group_name_is_rejected_rather_than_defaulted(db_session, monkeypatch, caplog):
+    """Silently falling back to Viewers is how a typo mints under-privileged
+    users for weeks. The API answers 400 for a default_group_id that does not
+    exist; env config gets the same answer, logged and survivable."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Nope")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None
+    assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
+    assert "Nope" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_group_name_leaves_the_previous_provider_intact(db_session, monkeypatch):
+    """Rejection happens before the upsert, so the running config survives a
+    bad edit -- the provider keeps working until the operator fixes the name."""
+    group = await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+    await apply_env_oidc_provider(db_session)
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Typo")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.default_group_id == group.id
+
+
+@pytest.mark.asyncio
+async def test_no_group_variable_leaves_the_default_group_unset(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id is None
+
+
+@pytest.mark.asyncio
+async def test_removing_the_group_variable_clears_the_default_group(db_session, monkeypatch):
+    """The environment is the whole truth for this row; a group that is no
+    longer declared must not linger, since the lock blocks removing it in the UI."""
+    await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+    await apply_env_oidc_provider(db_session)
+
+    monkeypatch.delenv("BAMBUDDY_OIDC_DEFAULT_GROUP")
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id is None
+
+
+@pytest.mark.asyncio
+async def test_an_empty_group_variable_counts_as_unset(db_session, monkeypatch):
+    """Same rule the required vars follow: an empty value in a compose file is
+    a forgotten value, not a request to reject the config."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.default_group_id is None
+
+
+# --- blank optional strings count as unset, not a refusal ---------------------
+# `.env.example` ships `# BAMBUDDY_OIDC_ICON_URL=` commented out, so uncommenting
+# it must not take the provider down -- same rule default_group already follows.
+
+
+@pytest.mark.asyncio
+async def test_a_blank_scopes_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_SCOPES="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.scopes == "openid email profile"
+
+
+@pytest.mark.asyncio
+async def test_a_blank_email_claim_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_EMAIL_CLAIM="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.email_claim == "email"
+
+
+@pytest.mark.asyncio
+async def test_a_blank_icon_url_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_ICON_URL="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.icon_url is None
+
+
+# --- account links and collision behavior ------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_renaming_to_match_a_ui_provider_adopts_it_and_releases_the_old_row(db_session, monkeypatch):
+    """New name collides with existing UI provider: env config adopts that row,
+    old env-managed row is released. Identity is the name, so the collision is
+    resolved by matching the new name against the table."""
+    # Start with env-managed "Keycloak"
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+
+    # Add a UI provider named "Authentik"
+    ui_provider = OIDCProvider(name="Authentik", issuer_url="https://auth.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+    ui_id = ui_provider.id
+
+    # Rename env provider to "Authentik" — matches the UI provider
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # The UI provider is adopted and becomes env-managed
+    provider = await _env_provider(db_session)
+    assert provider.id == ui_id, "adopted the UI provider"
+    assert provider.name == "Authentik"
+    assert provider.client_id == "bambuddy"  # updated from env
+    assert provider.is_env_managed is True
+
+    # The old Keycloak row is released
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    assert old.name == "Keycloak"
+    assert old.is_env_managed is False
+    assert old.is_enabled is False
+
+
+@pytest.mark.asyncio
+async def test_account_links_survive_a_provider_rename(db_session, monkeypatch):
+    """The provider row is never deleted, only updated: user_oidc_links FK
+    ON DELETE CASCADE must not be triggered by a rename."""
+    from backend.app.models.oidc_provider import UserOIDCLink
+    from backend.app.models.user import User
+
+    # Create a user and link it to the env-managed provider
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    provider_id = (await _env_provider(db_session)).id
+
+    user = User(username="testuser", email="test@example.com")
+    db_session.add(user)
+    await db_session.flush()
+
+    link = UserOIDCLink(
+        user_id=user.id,
+        provider_id=provider_id,
+        provider_user_id="oidc-sub-12345",
+        provider_email="test@idp.example.com",
+    )
+    db_session.add(link)
+    await db_session.commit()
+
+    # Rename the env provider
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # The link still exists, pointing to the old row (which is now released)
+    result = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_id == provider_id))
+    links = result.scalars().all()
+    assert len(links) == 1
+    assert links[0].provider_user_id == "oidc-sub-12345"
+
+
+@pytest.mark.asyncio
+async def test_renaming_with_autologin_updates_the_exclusivity_sweep(db_session, monkeypatch):
+    """When renamed env config has autologin=true, the sweep clears autologin
+    from other rows. The old row is released (autologin cleared there too)."""
+    # Setup: env provider "Keycloak" with autologin
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+    assert (await _env_provider(db_session)).is_autologin is True
+
+    # Another UI provider also has autologin
+    ui_provider = OIDCProvider(name="UI", issuer_url="https://ui.example.com", client_id="ui")
+    ui_provider.client_secret = "secret"
+    ui_provider.is_autologin = True
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    # Rename env provider to "Authentik" with autologin=true
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # New row is the autologin target
+    new_provider = await _env_provider(db_session)
+    assert new_provider.name == "Authentik"
+    assert new_provider.is_autologin is True
+
+    # Old row is released and autologin cleared
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    assert old.is_env_managed is False
+    assert old.is_autologin is False
+
+    # UI provider autologin is cleared (only env-managed can be autologin now)
+    await db_session.refresh(ui_provider)
+    assert ui_provider.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_group_name_matching_is_case_sensitive(db_session, monkeypatch, caplog):
+    """Group name is resolved by exact match; 'operators' != 'Operators'."""
+    await _group(db_session, "Operators")  # capital O
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="operators")  # lowercase
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    # Config is rejected
+    assert await _env_provider(db_session) is None
+    assert "operators" in caplog.text
+    assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_group_name_rejection_does_not_log_the_secret(db_session, monkeypatch, caplog):
+    """Group resolution happens before schema validation, so the secret is
+    not yet in scope, but verify it's not leaked by the error path."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="NonExistent")
+    secret = os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"]
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    # Config is rejected but secret is safe
+    assert await _env_provider(db_session) is None
+    assert secret not in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_restoring_env_config_after_rename_then_unset_finds_the_original_row(db_session, monkeypatch):
+    """Rename Keycloak → Authentik, unset everything, restore Keycloak.
+    Must re-enable the original row, not create a new one."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    # Rename to Authentik
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+    assert (await _env_provider(db_session)).name == "Authentik"
+
+    # Unset everything
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    # Restore the original Keycloak config
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    # Same row, re-enabled
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.name == "Keycloak"
+    assert provider.is_enabled is True
+    assert provider.is_env_managed is True

+ 137 - 0
backend/tests/integration/test_oidc_env_lock.py

@@ -0,0 +1,137 @@
+"""The env-managed provider is read-only through the API (#2593).
+
+Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so a UI edit
+would silently disappear at the next restart -- the operator would see their
+change accepted and then reverted, with nothing explaining why. Refusing the
+write is the honest answer.
+
+Locking it out is safe because BAMBUDDY_LOCAL_LOGIN (#1589) is the documented
+recovery path if the provider itself becomes unusable.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.oidc_provider import OIDCProvider
+from backend.tests.integration.test_mfa_api import _auth_header, _setup_and_login
+
+
+async def _env_managed_provider(db_session) -> int:
+    provider = OIDCProvider(
+        name="Env Keycloak",
+        issuer_url="https://sso.example.com/realms/main",
+        client_id="bambuddy",
+        icon_url="https://sso.example.com/logo.png",
+        is_env_managed=True,
+    )
+    provider.client_secret = "s3cr3t"
+    db_session.add(provider)
+    await db_session.commit()
+    await db_session.refresh(provider)
+    return provider.id
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_put_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockput", "envlockput123")
+
+    response = await async_client.put(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        json={"name": "hijacked"},
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_delete_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockdel", "envlockdel123")
+
+    response = await async_client.delete(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_icon_delete_is_refused(async_client: AsyncClient, db_session):
+    """The icon is part of the env config too -- BAMBUDDY_OIDC_ICON_URL."""
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockicondel", "envlockicondel123")
+
+    response = await async_client.delete(
+        f"/api/v1/auth/oidc/providers/{provider_id}/icon",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_icon_refresh_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockiconref", "envlockiconref123")
+
+    response = await async_client.post(
+        f"/api/v1/auth/oidc/providers/{provider_id}/icon/refresh",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_ui_provider_is_still_editable(async_client: AsyncClient):
+    """The lock must not leak onto providers the operator created themselves --
+    they coexist with the env one and stay fully editable."""
+    token = await _setup_and_login(async_client, "envlockui", "envlockui123")
+    created = await async_client.post(
+        "/api/v1/auth/oidc/providers",
+        json={
+            "name": "UI provider",
+            "issuer_url": "https://other.example.com",
+            "client_id": "ui",
+            "client_secret": "ui-secret",
+            "scopes": "openid",
+            "is_enabled": True,
+            "auto_create_users": False,
+        },
+        headers=_auth_header(token),
+    )
+    provider_id = created.json()["id"]
+
+    response = await async_client.put(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        json={"name": "Renamed"},
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 200
+    assert response.json()["name"] == "Renamed"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_response_says_which_provider_is_env_managed(async_client: AsyncClient, db_session):
+    """The frontend needs this to render the lock; without it the UI would show
+    editable fields whose writes the API then refuses."""
+    await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockflag", "envlockflag123")
+
+    response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
+
+    assert response.status_code == 200
+    providers = response.json()
+    assert any(p["is_env_managed"] for p in providers)

+ 43 - 0
backend/tests/integration/test_oidc_env_startup.py

@@ -0,0 +1,43 @@
+"""The env provider is applied on startup, not merely appliable (#2593).
+
+test_oidc_env_apply.py calls apply_env_oidc_provider() directly, so it stays
+green even if nothing ever calls it -- deleting the lifespan call would leave
+the feature dead with a fully passing suite. These tests pin the call site.
+
+They read the lifespan's source rather than running it: the function is ~460
+lines and starts printer connections, MQTT and schedulers, so executing it
+here would test everything except the one line in question. That makes this a
+wiring check, not a behavioural one -- it proves the call exists and runs
+after migrations, and deliberately proves nothing about what it does. The
+behaviour is covered by test_oidc_env_apply.py.
+"""
+
+from __future__ import annotations
+
+import inspect
+
+from backend.app.main import lifespan
+
+
+def _lifespan_source() -> str:
+    return inspect.getsource(lifespan)
+
+
+def test_lifespan_applies_the_env_oidc_provider():
+    assert "apply_env_oidc_provider(" in _lifespan_source()
+
+
+def test_it_runs_after_the_migrations():
+    """is_env_managed does not exist until run_migrations has added it, so an
+    upsert before init_db() would fail on every existing installation."""
+    source = _lifespan_source()
+    assert source.index("await init_db()") < source.index("apply_env_oidc_provider(")
+
+
+def test_the_apply_call_is_awaited():
+    """apply_env_oidc_provider is a coroutine; calling it without await would
+    return an un-awaited coroutine and silently apply nothing."""
+    source = _lifespan_source()
+    call = source.index("apply_env_oidc_provider(")
+    line_start = source.rindex("\n", 0, call) + 1
+    assert source[line_start:call].strip().endswith("await")

+ 126 - 0
backend/tests/unit/test_oidc_env_managed_migration.py

@@ -0,0 +1,126 @@
+"""The is_env_managed column has to reach databases that already exist (#2593).
+
+The model test covers a table freshly created from metadata, which is not how
+an upgrade arrives: an installed instance has an oidc_providers table without
+the column, and only run_migrations adds it there. Every boot re-runs the whole
+migration set, so adding it twice must be a no-op rather than an error.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import Base, run_migrations
+
+
+def _register_all_models():
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        library,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@pytest.fixture
+async def engine():
+    """A database as it stands before this change: every table created from the
+    models, then the new column dropped again -- the model already declares it,
+    so only removing it reproduces what an installed instance actually has."""
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+        await conn.execute(text("ALTER TABLE oidc_providers DROP COLUMN is_env_managed"))
+    yield eng
+    await eng.dispose()
+
+
+async def _columns(conn) -> set[str]:
+    rows = await conn.execute(text("PRAGMA table_info(oidc_providers)"))
+    return {r[1] for r in rows}
+
+
+@pytest.mark.asyncio
+async def test_migration_adds_the_column_to_an_existing_table(engine):
+    async with engine.connect() as conn:
+        assert "is_env_managed" not in await _columns(conn)
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert "is_env_managed" in await _columns(conn)
+
+
+@pytest.mark.asyncio
+async def test_existing_rows_default_to_not_env_managed(engine):
+    """A provider created through the UI before the upgrade must not come back
+    locked -- is_env_managed decides whether the API refuses to edit it."""
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO oidc_providers"
+                " (id, name, issuer_url, client_id, client_secret, scopes, is_enabled,"
+                "  auto_create_users, auto_link_existing_accounts, email_claim,"
+                "  require_email_verified)"
+                " VALUES (1, 'UI provider', 'https://sso.example', 'app', 'enc',"
+                "  'openid email profile', 1, 0, 0, 'email', 1)"
+            )
+        )
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        row = await conn.execute(text("SELECT is_env_managed FROM oidc_providers WHERE id = 1"))
+        assert not row.scalar()
+
+
+@pytest.mark.asyncio
+async def test_it_is_idempotent(engine):
+    """Every boot re-runs the migration set."""
+    for _ in range(2):
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert "is_env_managed" in await _columns(conn)

+ 12 - 0
backend/tests/unit/test_oidc_env_provider.py

@@ -0,0 +1,12 @@
+import pytest
+
+from backend.app.models.oidc_provider import OIDCProvider
+
+
+@pytest.mark.asyncio
+async def test_is_env_managed_defaults_false(db_session):
+    p = OIDCProvider(name="x", issuer_url="https://i", client_id="c", client_secret="s")
+    db_session.add(p)
+    await db_session.commit()
+    await db_session.refresh(p)
+    assert p.is_env_managed is False

+ 248 - 0
backend/tests/unit/test_oidc_env_reader.py

@@ -0,0 +1,248 @@
+"""BAMBUDDY_OIDC_* reader (#2593).
+
+The reader is deliberately dumb: it maps env vars to field names and applies
+defaults. Whether the resulting provider is *valid* is decided later, by the
+same OIDCProviderCreate schema the API uses, so env config cannot bypass a
+check the UI enforces.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.app.core.oidc_env import EnvOIDCConfigError, env_bool, read_env_oidc_config
+
+REQUIRED = {
+    "BAMBUDDY_OIDC_NAME": "Keycloak",
+    "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
+    "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
+    "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
+}
+
+OPTIONAL = (
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_env(monkeypatch):
+    for key in (*REQUIRED, *OPTIONAL):
+        monkeypatch.delenv(key, raising=False)
+
+
+def _set_required(monkeypatch):
+    for key, value in REQUIRED.items():
+        monkeypatch.setenv(key, value)
+
+
+def test_returns_none_when_nothing_is_configured():
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("missing", sorted(REQUIRED))
+def test_returns_none_when_any_single_required_var_is_missing(monkeypatch, missing):
+    """All four or nothing -- a half-configured provider must not reach the
+    database, where it would fail at authorize time instead of at startup."""
+    _set_required(monkeypatch)
+    monkeypatch.delenv(missing)
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("raw", ["", "   ", "\n", " \t\n "])
+@pytest.mark.parametrize("key", sorted(REQUIRED))
+def test_an_empty_required_var_counts_as_unset(monkeypatch, key, raw):
+    """`BAMBUDDY_OIDC_CLIENT_SECRET=` in a compose file is a forgotten value,
+    not an intentional empty secret -- and neither is one holding only
+    whitespace, which the optional vars have always treated as unset."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv(key, raw)
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("key", sorted(REQUIRED))
+def test_a_required_var_is_stripped(monkeypatch, key):
+    """A Kubernetes Secret written as a block scalar carries a trailing
+    newline, and the schema bounds these four by max_length only -- so an
+    unstripped issuer_url reaches the database, enables the SSO button and
+    then raises httpx.InvalidURL on the first click, long after startup could
+    have refused it."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv(key, f"  {REQUIRED[key]}\n")
+
+    cfg = read_env_oidc_config()
+    field = {
+        "BAMBUDDY_OIDC_NAME": "name",
+        "BAMBUDDY_OIDC_ISSUER_URL": "issuer_url",
+        "BAMBUDDY_OIDC_CLIENT_ID": "client_id",
+        "BAMBUDDY_OIDC_CLIENT_SECRET": "client_secret",
+    }[key]
+    assert cfg[field] == REQUIRED[key]
+
+
+def test_reads_the_required_vars(monkeypatch):
+    _set_required(monkeypatch)
+    cfg = read_env_oidc_config()
+    assert cfg["name"] == "Keycloak"
+    assert cfg["issuer_url"] == "https://sso.example.com/realms/main"
+    assert cfg["client_id"] == "bambuddy"
+    assert cfg["client_secret"] == "s3cr3t"
+
+
+def test_applies_the_documented_defaults(monkeypatch):
+    _set_required(monkeypatch)
+    cfg = read_env_oidc_config()
+    assert cfg["scopes"] == "openid email profile"
+    assert cfg["is_enabled"] is True
+    assert cfg["auto_create_users"] is False
+    assert cfg["auto_link_existing_accounts"] is False
+    assert cfg["email_claim"] == "email"
+    assert cfg["require_email_verified"] is True
+    assert cfg["icon_url"] is None
+    assert cfg["is_autologin"] is False
+
+
+@pytest.mark.parametrize("raw", ["true", "TRUE", "True", "1", "yes", "YES", " yes "])
+def test_booleans_accept_the_project_truthy_spellings(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    assert read_env_oidc_config()["auto_create_users"] is True
+
+
+@pytest.mark.parametrize("raw", ["false", "FALSE", "False", "0", "no", "NO"])
+def test_falsy_values_are_false(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    assert read_env_oidc_config()["auto_create_users"] is False
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+def test_an_unrecognized_boolean_is_rejected(monkeypatch, raw):
+    """Only the documented spellings are accepted; an unrecognised value must
+    not silently turn a flag on or off -- it must refuse the whole config
+    instead of guessing (M-R4 strict boolean parsing)."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    with pytest.raises(EnvOIDCConfigError, match="BAMBUDDY_OIDC_AUTO_CREATE_USERS"):
+        read_env_oidc_config()
+
+
+def test_a_boolean_default_of_true_can_be_turned_off(monkeypatch):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "false")
+    assert read_env_oidc_config()["require_email_verified"] is False
+
+
+# --- env_bool, tested directly ------------------------------------------------
+# The reader-level tests above pin the contract through read_env_oidc_config;
+# these exercise the helper itself so its default/blank/reject behavior is
+# proven independently of any particular BAMBUDDY_OIDC_* field.
+
+
+@pytest.mark.parametrize("raw", ["false", "FALSE", "0", "no", "NO"])
+def test_env_bool_falsy_values_are_false(monkeypatch, raw):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", True) is False
+
+
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_absent_is_the_given_default(monkeypatch, default):
+    monkeypatch.delenv("SOME_FLAG", raising=False)
+    assert env_bool("SOME_FLAG", default) is default
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_blank_is_the_given_default(monkeypatch, raw, default):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", default) is default
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+def test_env_bool_rejects_an_unrecognized_value(monkeypatch, raw):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    with pytest.raises(EnvOIDCConfigError, match="SOME_FLAG"):
+        env_bool("SOME_FLAG", True)
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_lenient_falls_back_to_default_on_unrecognized(monkeypatch, raw, default):
+    """strict=False (the request-path callers like BAMBUDDY_LOCAL_LOGIN): an
+    unrecognized value must return the default, never raise -- a raise there
+    would 500 a live endpoint rather than skip a startup config."""
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", default, strict=False) is default
+
+
+def test_optional_strings_override_their_defaults(monkeypatch):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", "openid profile groups")
+    monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", "mail")
+    monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", "https://sso.example.com/logo.png")
+    cfg = read_env_oidc_config()
+    assert cfg["scopes"] == "openid profile groups"
+    assert cfg["email_claim"] == "mail"
+    assert cfg["icon_url"] == "https://sso.example.com/logo.png"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_scopes_is_unset(monkeypatch, raw):
+    """`BAMBUDDY_OIDC_SCOPES=` in a compose file is a forgotten value, not a
+    request for a provider with no scopes -- same rule as default_group."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", raw)
+    assert read_env_oidc_config()["scopes"] == "openid email profile"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_email_claim_is_unset(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", raw)
+    assert read_env_oidc_config()["email_claim"] == "email"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_icon_url_is_unset(monkeypatch, raw):
+    """Uncommenting `# BAMBUDDY_OIDC_ICON_URL=` in .env.example must not take
+    the provider down -- the reader must still return a config, not refuse it."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", raw)
+    cfg = read_env_oidc_config()
+    assert cfg is not None, "a blank optional var must not refuse the whole provider"
+    assert cfg["icon_url"] is None
+
+
+def test_the_default_group_is_read_as_a_name(monkeypatch):
+    """A name, not an id: group ids differ per install, so an id in a compose
+    file would point at whatever group happened to be created third."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Operators")
+    cfg = read_env_oidc_config()
+    assert cfg["default_group"] == "Operators"
+    assert "default_group_id" not in cfg, "resolution needs the database, not the reader"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_default_group_is_unset(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", raw)
+    assert read_env_oidc_config()["default_group"] is None
+
+
+def test_every_var_the_reader_knows_is_registered_in_the_typo_guard():
+    """An unregistered BAMBUDDY_* var logs "possible typo" at every boot, which
+    would tell operators their correct config is wrong. Asserted against the
+    reader's own vars rather than a copied list, so a var added later is caught
+    here instead of in someone's logs."""
+    from backend.app.core.config import _INTENTIONAL_UNSETTINGS
+
+    unregistered = {v for v in (*REQUIRED, *OPTIONAL) if v not in _INTENTIONAL_UNSETTINGS}
+    assert not unregistered

+ 91 - 0
frontend/src/__tests__/components/OIDCProviderSettings.test.tsx

@@ -325,3 +325,94 @@ describe('OIDCProviderSettings', () => {
     });
   });
 });
+
+describe('env-managed provider (#2593)', () => {
+  const envManagedProvider = {
+    ...mockProviders[0],
+    id: 2,
+    name: 'EnvIdP',
+    is_env_managed: true,
+  };
+
+  it('marks the provider as environment managed', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([envManagedProvider]))
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('EnvIdP')).toBeInTheDocument();
+    });
+    expect(screen.getByText(/Environment Managed/i)).toBeInTheDocument();
+  });
+
+  it('offers no edit or delete control for it', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([envManagedProvider]))
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('EnvIdP')).toBeInTheDocument();
+    });
+    // Startup rewrites this row from the environment on every boot, and the API
+    // answers 409 — offering the controls would promise an edit that cannot land.
+    expect(screen.queryByTestId('edit-provider-2')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('delete-provider-2')).not.toBeInTheDocument();
+  });
+
+  it('offers no icon controls for it either', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () =>
+        HttpResponse.json([
+          { ...envManagedProvider, icon_url: 'https://idp.example.com/i.png', has_icon: true },
+        ])
+      )
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('EnvIdP')).toBeInTheDocument();
+    });
+    // Both icon routes answer 409 for an env-managed provider, so a click could
+    // only ever produce an error toast — the same reason the rest are hidden.
+    expect(screen.queryByTestId('refresh-icon-2')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('remove-icon-2')).not.toBeInTheDocument();
+  });
+
+  it('still offers them for a UI-created provider', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () =>
+        HttpResponse.json([{ ...mockProviders[0], is_env_managed: false }])
+      )
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('TestIdP')).toBeInTheDocument();
+    });
+    expect(screen.getByTestId('edit-provider-1')).toBeInTheDocument();
+    expect(screen.getByTestId('delete-provider-1')).toBeInTheDocument();
+  });
+
+  it('hides the enable/disable toggle for env-managed providers', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () =>
+        HttpResponse.json([
+          { ...mockProviders[0], id: 2, name: 'EnvIdP', is_enabled: true, is_env_managed: true },
+          { ...mockProviders[0], id: 3, name: 'UiIdP', is_enabled: true, is_env_managed: false },
+        ])
+      )
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('EnvIdP')).toBeInTheDocument();
+      expect(screen.getByText('UiIdP')).toBeInTheDocument();
+    });
+    // The toggle carries no testid, so it is counted: two cards are rendered and
+    // exactly one switch may exist — the UI provider's. Enabling the env-managed
+    // one would be reverted by the next boot, and the API answers 409.
+    expect(screen.getAllByRole('switch')).toHaveLength(1);
+  });
+});

+ 139 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -198,17 +198,156 @@ describe('QueuePage', () => {
     });
 
     it('shows one if-started-now ETA for an eligible pending item', async () => {
+      // Printer 1 is free: nothing is printing on it and nothing is queued ahead.
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([mockQueueItems[0]]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Test Print 1');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      const etaEl = within(row as HTMLElement).getAllByTestId('queue-item-eta');
+      expect(etaEl).toHaveLength(1);
+      // The tooltip must say what the number actually means, not reuse the
+      // printers-page "Estimated completion time" wording (which this is not).
+      expect(etaEl[0]).toHaveAttribute(
+        'title',
+        'Completion time if this job started now',
+      );
+    });
+
+    // The scheduler only writes waiting_reason on the model-based assignment
+    // path, so an item pinned to a specific printer carries no marker at all
+    // while it sits behind a running job. Without the printer-busy check every
+    // one of these quoted the same wrong "starts now" time.
+    it('does not show an ETA for an item pinned behind a running print', async () => {
       render(<QueuePage />);
 
+      // mockQueueItems[1] ("Active Print") is printing on printer 1, and
+      // "Test Print 1" is pending on the same printer with waiting_reason null.
       const name = await screen.findByText('Test Print 1');
       const row = name.closest('.group');
 
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('shows the ETA only on the next item up when several share an idle printer', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            { ...mockQueueItems[0], id: 10, position: 1, archive_name: 'First up' },
+            { ...mockQueueItems[0], id: 11, position: 2, archive_name: 'Second up' },
+            { ...mockQueueItems[0], id: 12, position: 3, archive_name: 'Third up' },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      await screen.findByText('Third up');
+
+      const etaRowNames = screen
+        .queryAllByTestId('queue-item-eta')
+        .map((el) => el.closest('.group')?.textContent);
+
+      expect(etaRowNames).toHaveLength(1);
+      expect(etaRowNames[0]).toContain('First up');
+    });
+
+    it('shows an ETA for a staged item queued behind others on an idle printer', async () => {
+      // The scheduler skips manual-start items without claiming the printer, so
+      // a staged job is startable whenever its printer is free — queue order
+      // does not gate it.
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            { ...mockQueueItems[0], id: 20, position: 1, archive_name: 'Auto first' },
+            {
+              ...mockQueueItems[0],
+              id: 21,
+              position: 2,
+              archive_name: 'Staged second',
+              manual_start: true,
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Staged second');
+      const row = name.closest('.group');
+
       expect(row).not.toBeNull();
       expect(
         within(row as HTMLElement).getAllByTestId('queue-item-eta'),
       ).toHaveLength(1);
     });
 
+    it('does not show an ETA for an item conditional on a previous print', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Conditional Print',
+              require_previous_success: true,
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Conditional Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('advances the ETA as time passes', async () => {
+      vi.useFakeTimers({ shouldAdvanceTime: true });
+      vi.setSystemTime(new Date('2026-08-02T10:00:00Z'));
+
+      try {
+        server.use(
+          http.get('/api/v1/queue/', () => {
+            return HttpResponse.json([mockQueueItems[0]]);
+          }),
+        );
+
+        render(<QueuePage />);
+
+        const name = await screen.findByText('Test Print 1');
+        const row = name.closest('.group') as HTMLElement;
+        const before = within(row).getByTestId('queue-item-eta').textContent;
+
+        // The queue payload never changes, so react-query hands back the same
+        // object and nothing here re-renders on its own. Only the page's own
+        // clock can move this value.
+        await vi.advanceTimersByTimeAsync(45 * 60 * 1000);
+
+        await waitFor(() => {
+          expect(
+            within(row).getByTestId('queue-item-eta').textContent,
+          ).not.toBe(before);
+        });
+      } finally {
+        vi.useRealTimers();
+      }
+    });
+
     it('shows one if-started-now ETA for a staged item', async () => {
       server.use(
         http.get('/api/v1/queue/', () => {

+ 17 - 0
frontend/src/__tests__/utils/date.test.ts

@@ -339,6 +339,23 @@ describe('formatETA', () => {
     const result = formatETA(60 * 48); // 48 hours from now
     expect(result).not.toContain('Tomorrow');
   });
+
+  it('counts from baseTime when one is supplied', () => {
+    const base = new Date('2025-06-15T12:00:00Z').getTime();
+    // Same offset, two different starting instants: the results must differ by
+    // exactly the gap between those instants, not track the system clock.
+    const atNoon = formatETA(60, '24h', undefined, base);
+    const anHourLater = formatETA(60, '24h', undefined, base + 60 * 60 * 1000);
+
+    expect(atNoon).not.toBe(anHourLater);
+    expect(formatETA(60, '24h', undefined, base)).toBe(atNoon);
+    expect(formatETA(120, '24h', undefined, base)).toBe(anHourLater);
+  });
+
+  it('falls back to the system clock without baseTime', () => {
+    const explicit = formatETA(60, '24h', undefined, Date.now());
+    expect(formatETA(60, '24h')).toBe(explicit);
+  });
 });
 
 describe('formatDuration', () => {

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

@@ -3548,6 +3548,11 @@ export interface OIDCProvider {
   // #1589: when true, the LoginPage redirects unauthenticated visitors
   // straight to this provider on mount. At most one provider may carry this.
   is_autologin: boolean;
+  // #2593: defined by BAMBUDDY_OIDC_* and rewritten from the environment on
+  // every boot. The API answers 409 to any write, so the settings UI must not
+  // offer edit/delete controls that cannot succeed. Optional so a response
+  // from an older backend still type-checks.
+  is_env_managed?: boolean;
 }
 
 export interface OIDCProviderCreate {

+ 58 - 39
frontend/src/components/OIDCProviderSettings.tsx

@@ -1,6 +1,6 @@
 import { useState, type ReactNode } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff } from 'lucide-react';
+import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff, Lock } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client';
@@ -386,6 +386,11 @@ export function OIDCProviderSettings() {
                       <X className="w-3 h-3" /> {t('common.disabled')}
                     </span>
                   )}
+                  {provider.is_env_managed && (
+                    <span className="flex items-center gap-1 text-xs text-bambu-green">
+                      <Lock className="w-3 h-3" /> {t('settings.environmentManagedLabel')}
+                    </span>
+                  )}
                 </div>
                 <div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
                   <ExternalLink className="w-3 h-3" />
@@ -393,45 +398,59 @@ export function OIDCProviderSettings() {
                 </div>
               </div>
               <div className="flex items-center gap-2">
-                {provider.icon_url && (
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => refreshIconMutation.mutate(provider.id)}
-                    disabled={refreshIconMutation.isPending}
-                    title={t('settings.oidc.refreshIcon')}
-                    data-testid={`refresh-icon-${provider.id}`}
-                  >
-                    <RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
-                  </Button>
+                {/* #2593: startup rewrites the env-managed row from BAMBUDDY_OIDC_*
+                    and the API answers 409, so offering any of these would promise
+                    a change that cannot land -- the icon routes included, where the
+                    click only ever produced an error toast. */}
+                {!provider.is_env_managed && (
+                  <>
+                    {provider.icon_url && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => refreshIconMutation.mutate(provider.id)}
+                        disabled={refreshIconMutation.isPending}
+                        title={t('settings.oidc.refreshIcon')}
+                        data-testid={`refresh-icon-${provider.id}`}
+                      >
+                        <RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
+                      </Button>
+                    )}
+                    {provider.has_icon && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => removeIconMutation.mutate(provider.id)}
+                        disabled={removeIconMutation.isPending}
+                        title={t('settings.oidc.removeIcon')}
+                        data-testid={`remove-icon-${provider.id}`}
+                      >
+                        <ImageOff className="w-4 h-4" />
+                      </Button>
+                    )}
+                    <Toggle
+                      checked={provider.is_enabled}
+                      onChange={() => toggleEnabled(provider)}
+                      disabled={updateMutation.isPending}
+                    />
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
+                      data-testid={`edit-provider-${provider.id}`}
+                    >
+                      <Edit2 className="w-4 h-4" />
+                    </Button>
+                    <Button
+                      variant="danger"
+                      size="sm"
+                      onClick={() => setDeleteTarget(provider)}
+                      data-testid={`delete-provider-${provider.id}`}
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </Button>
+                  </>
                 )}
-                {provider.has_icon && (
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => removeIconMutation.mutate(provider.id)}
-                    disabled={removeIconMutation.isPending}
-                    title={t('settings.oidc.removeIcon')}
-                    data-testid={`remove-icon-${provider.id}`}
-                  >
-                    <ImageOff className="w-4 h-4" />
-                  </Button>
-                )}
-                <Toggle
-                  checked={provider.is_enabled}
-                  onChange={() => toggleEnabled(provider)}
-                  disabled={updateMutation.isPending}
-                />
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
-                >
-                  <Edit2 className="w-4 h-4" />
-                </Button>
-                <Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
-                  <Trash2 className="w-4 h-4" />
-                </Button>
               </div>
             </div>
           </CardHeader>

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

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Fertigstellungszeit, wenn dieser Auftrag jetzt starten würde',
       asap: 'Sofort',
       overdue: 'Überfällig',
       now: 'Jetzt',

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

@@ -1308,6 +1308,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Completion time if this job started now',
       asap: 'ASAP',
       overdue: 'Overdue',
       now: 'Now',

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

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Hora de finalización si este trabajo comenzara ahora',
       asap: 'Lo antes posible',
       overdue: 'Atrasada',
       now: 'Ahora',

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

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Heure de fin si cette tâche démarrait maintenant',
       asap: 'Dès que possible',
       overdue: 'En retard',
       now: 'Maintenant',

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

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Orario di completamento se questo lavoro iniziasse ora',
       asap: 'ASAP',
       overdue: 'Scaduto',
       now: 'Ora',

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

@@ -1292,6 +1292,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'このジョブを今開始した場合の完了予定時刻',
       asap: '即時',
       overdue: '期限超過',
       now: '今すぐ',

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

@@ -1223,6 +1223,7 @@ export default {
       description: '아카이브 페이지의 컨텍스트 메뉴에서 "예약" 옵션을 사용하거나 파일을 드래그 앤 드롭하여 시작하세요.'
     },
     time: {
+      etaIfStartedNow: '이 작업을 지금 시작할 경우의 완료 예정 시각',
       asap: '즉시',
       overdue: '기한 초과',
       now: '지금',

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

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Horário de conclusão se este trabalho começasse agora',
       asap: 'ASAP',
       overdue: 'Atrasado',
       now: 'Agora',

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

@@ -1233,6 +1233,7 @@ export default {
       description: "Запланируйте печать на странице архива через пункт «Запланировать» в контекстном меню либо перетащите сюда файлы.",
     },
     time: {
+      etaIfStartedNow: "Время завершения, если запустить это задание сейчас",
       asap: "Как можно скорее",
       overdue: "Просрочено",
       now: "Сейчас",

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

@@ -1293,6 +1293,7 @@ export default {
     },
     // Zaman
     time: {
+      etaIfStartedNow: 'Bu iş şimdi başlatılırsa tamamlanma saati',
       asap: 'ASAP',
       overdue: 'Gecikmiş',
       now: 'Şimdi',

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

@@ -1308,6 +1308,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: "Час завершення, якщо запустити це завдання зараз",
       asap: "Якнайшвидше",
       overdue: "Прострочено",
       now: "Зараз",

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

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: '若此任务现在开始的预计完成时间',
       asap: '尽快',
       overdue: '已逾期',
       now: '现在',

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

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: '若此工作現在開始的預計完成時間',
       asap: '儘快',
       overdue: '已逾期',
       now: '現在',

+ 131 - 9
frontend/src/pages/QueuePage.tsx

@@ -356,6 +356,8 @@ function SortableQueueItem({
   hasPermission,
   canModify,
   printerState,
+  showEta = false,
+  etaNow,
   t,
 }: {
   item: PrintQueueItem;
@@ -377,6 +379,11 @@ function SortableQueueItem({
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
   printerState?: string | null;
+  // Whether this item qualifies for an "if started now" ETA (#2740), and the
+  // instant to measure it from. Both are decided by the page so every row on
+  // screen quotes the same clock.
+  showEta?: boolean;
+  etaNow?: number;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
   // Fetch printer status every 30 seconds while printing to monitor progress
@@ -428,15 +435,14 @@ function SortableQueueItem({
   const isPending = item.status === 'pending';
   const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
 
-  // This is an "if started now" estimate, not a cumulative queue forecast.
-  // Do not show it for active, blocked, scheduled, or invalid queue items.
+  // This is an "if started now" estimate, not a cumulative queue forecast, so
+  // it is only shown for items the page determined could actually start now
+  // (see etaEligibleIds). etaNow is the caller's ticking clock — deriving the
+  // ETA from it rather than from Date.now() keeps this render deterministic and
+  // stops the value freezing at first paint.
   const queueItemEta =
-    isPending &&
-    !item.waiting_reason &&
-    !item.scheduled_time &&
-    item.print_time_seconds != null &&
-    item.print_time_seconds > 0
-      ? formatETA(item.print_time_seconds / 60, timeFormat, t)
+    isPending && showEta && item.print_time_seconds != null && item.print_time_seconds > 0
+      ? formatETA(item.print_time_seconds / 60, timeFormat, t, etaNow)
       : null;
 
   const isMobileSelectable = isPending && onToggleSelect;
@@ -618,7 +624,7 @@ function SortableQueueItem({
               <span
                 data-testid="queue-item-eta"
                 className="text-bambu-green font-medium"
-                title={t('printers.estimatedCompletion')}
+                title={t('queue.time.etaIfStartedNow')}
               >
                 ETA {queueItemEta}
               </span>
@@ -877,6 +883,10 @@ interface QueueRowRenderProps {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   canModify: (resource: any, action: any, createdById?: number | null) => boolean;
   t: (key: string, options?: Record<string, unknown>) => string;
+  // Items that qualify for an "if started now" ETA, and the shared clock it is
+  // measured from (#2740).
+  etaEligibleIds: Set<number>;
+  etaNow: number;
   aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number };
   // Mobile tap-to-reorder (#2667). onMoveUp/onMoveDown move this whole row
   // (single item or batch) one step among its siblings; onMoveBlock is the
@@ -902,6 +912,8 @@ function QueueRowRender(props: QueueRowRenderProps) {
     hasPermission,
     canModify,
     t,
+    etaEligibleIds,
+    etaNow,
     onMoveUp,
     onMoveDown,
   } = props;
@@ -923,6 +935,8 @@ function QueueRowRender(props: QueueRowRenderProps) {
         onToggleSelect={() => handleToggleSelect(row.item.id)}
         hasPermission={hasPermission}
         canModify={canModify}
+        showEta={etaEligibleIds.has(row.item.id)}
+        etaNow={etaNow}
         t={t}
       />
     );
@@ -948,6 +962,8 @@ function SortableBatchRow({
   hasPermission,
   canModify,
   t,
+  etaEligibleIds,
+  etaNow,
   aggregateForRows,
   onMoveUp,
   onMoveDown,
@@ -1139,6 +1155,8 @@ function SortableBatchRow({
               onToggleSelect={() => handleToggleSelect(child.id)}
               hasPermission={hasPermission}
               canModify={canModify}
+              showEta={etaEligibleIds.has(child.id)}
+              etaNow={etaNow}
               t={t}
             />
           ))}
@@ -1779,6 +1797,106 @@ export function QueuePage() {
     return items;
   }, [queue, filterLocation, matchesLocationFilter]);
 
+  // Queue items eligible for an "if started now" ETA (#2740).
+  //
+  // The ETA answers "when would this finish if it began right now", so it may
+  // only appear on items that really could begin right now. Deriving that from
+  // waiting_reason alone is not enough: the scheduler only writes that field on
+  // the model-based assignment path (print_scheduler.py), so an item pinned to a
+  // specific printer sits behind a running job with waiting_reason still NULL.
+  //
+  // Computed from the unfiltered queue on purpose — hiding a printer behind the
+  // location filter must not make its printer look free.
+  const etaEligibleIds = useMemo(() => {
+    const eligible = new Set<number>();
+    if (!queue) return eligible;
+
+    const busyPrinters = new Set<number>();
+    queue.forEach(item => {
+      if (item.status === 'printing' && item.printer_id) busyPrinters.add(item.printer_id);
+    });
+
+    const isFutureScheduled = (item: PrintQueueItem): boolean => {
+      if (!item.scheduled_time) return false;
+      return (parseUTCDate(item.scheduled_time)?.getTime() ?? 0) > Date.now();
+    };
+
+    // Mirrors the scheduler's own ordering so "next up" here means the item the
+    // scheduler would actually dispatch next, not whatever the user sorted by.
+    const schedulerOrder = (a: PrintQueueItem, b: PrintQueueItem): number => {
+      if (settings?.queue_shortest_first) {
+        const aJumped = a.been_jumped ? 1 : 0;
+        const bJumped = b.been_jumped ? 1 : 0;
+        if (aJumped !== bJumped) return bJumped - aJumped;
+        const aTime = a.print_time_seconds ?? Infinity;
+        const bTime = b.print_time_seconds ?? Infinity;
+        if (aTime !== bTime) return aTime - bTime;
+      }
+      return a.position - b.position;
+    };
+
+    // Claimants for each printer, in the order the scheduler would take them.
+    // Staged and future-scheduled items are excluded: the scheduler skips both
+    // without marking the printer busy, so neither holds up the item behind it.
+    const contenders = new Map<number, PrintQueueItem[]>();
+    queue
+      .filter(
+        item =>
+          item.status === 'pending' &&
+          item.printer_id != null &&
+          !item.manual_start &&
+          !isFutureScheduled(item)
+      )
+      .sort(schedulerOrder)
+      .forEach(item => {
+        const list = contenders.get(item.printer_id!) ?? [];
+        list.push(item);
+        contenders.set(item.printer_id!, list);
+      });
+
+    queue.forEach(item => {
+      if (item.status !== 'pending') return;
+      // Blocked, scheduled for later, or no usable duration to add.
+      if (item.waiting_reason) return;
+      if (isFutureScheduled(item)) return;
+      if (item.print_time_seconds == null || item.print_time_seconds <= 0) return;
+      // Conditional on an earlier print's outcome, which the UI cannot see: the
+      // scheduler may skip it outright rather than ever running it.
+      if (item.require_previous_success) return;
+
+      // Model-based items have no printer yet; an empty waiting_reason is the
+      // scheduler saying it found one, so trust that.
+      if (item.printer_id == null) {
+        eligible.add(item.id);
+        return;
+      }
+
+      if (busyPrinters.has(item.printer_id)) return;
+      // Staged items wait on the user, not on the queue, so they are startable
+      // whenever their printer is free regardless of what is queued ahead.
+      if (item.manual_start) {
+        eligible.add(item.id);
+        return;
+      }
+      if (contenders.get(item.printer_id)?.[0]?.id === item.id) eligible.add(item.id);
+    });
+
+    return eligible;
+  }, [queue, settings?.queue_shortest_first]);
+
+  // The ETA is "now + duration", so it goes stale on its own. Nothing else
+  // re-renders these rows while the queue payload is unchanged (react-query's
+  // structural sharing keeps the reference stable), so drive it from a clock of
+  // our own. Only runs while an ETA is actually on screen.
+  const [etaNow, setEtaNow] = useState(() => Date.now());
+  const hasEtas = etaEligibleIds.size > 0;
+  useEffect(() => {
+    if (!hasEtas) return;
+    setEtaNow(Date.now());
+    const id = setInterval(() => setEtaNow(Date.now()), 30000);
+    return () => clearInterval(id);
+  }, [hasEtas]);
+
   // Get unique printer IDs from active items to fetch their statuses
   const activePrinterIds = useMemo(() => {
     const ids = new Set<number>();
@@ -2567,6 +2685,8 @@ export function QueuePage() {
                           hasPermission={hasPermission}
                           canModify={canModify}
                           t={t}
+                          etaEligibleIds={etaEligibleIds}
+                          etaNow={etaNow}
                           aggregateForRows={aggregateForRows}
                           {...rowMovers(groupedRows, idx)}
                           onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
@@ -2605,6 +2725,8 @@ export function QueuePage() {
                                   hasPermission={hasPermission}
                                   canModify={canModify}
                                   t={t}
+                                  etaEligibleIds={etaEligibleIds}
+                                  etaNow={etaNow}
                                   aggregateForRows={aggregateForRows}
                                   {...rowMovers(bucket.rows, idx)}
                                   onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}

+ 8 - 2
frontend/src/utils/date.ts

@@ -319,14 +319,20 @@ export function formatTimeOnly(
  * @param remainingMinutes - Minutes until completion
  * @param timeFormat - Time format setting ('system', '12h', '24h')
  * @param t - Optional i18n translation function
+ * @param baseTime - Instant to count from, in epoch ms. Defaults to the current
+ *   clock. Callers that render an ETA for something not yet started must pass a
+ *   value that changes over time, or the string freezes at first render: it is
+ *   only recomputed when the component re-renders, which does not happen while
+ *   the underlying data is unchanged (#2740).
  * @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM")
  */
 export function formatETA(
   remainingMinutes: number,
   timeFormat: TimeFormat = 'system',
-  t?: (key: string) => string
+  t?: (key: string) => string,
+  baseTime?: number
 ): string {
-  const now = new Date();
+  const now = baseTime != null ? new Date(baseTime) : new Date();
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
 
   const today = new Date(now);

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-DPZgvI9N.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-CbDmTKuP.js"></script>
+    <script type="module" crossorigin src="/assets/index-DPZgvI9N.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini