diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fac335c5..e2358a15b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4.7] - 2026-06-14 ### Added +- **Sponsor-prompt thresholds lowered to fire for typical new installs** — The in-app sponsor toast in `useSponsorPrompt` was calibrated for power users: the lowest print milestone was `100`, the lowest archive milestone was `50`, the lowest filament-cost milestone was `100`. A check of recent Matomo data showed the toast firing very rarely (`?from=app-toast-prints-100` = 4 visits, `?from=app-toast-archives-50` = 3 visits in a 7-day window) — most installs simply never reach those bars, especially with the install base ~doubling since March. Calibration widened: `PRINT_MILESTONES` now `(10, 25, 100, 500, 1000, 2500, 5000)`, `ARCHIVE_MILESTONES` now `(5, 10, 50, 250, 1000)`, `COST_MILESTONES` now `(25, 50, 100, 500, 1000)`. The existing priority order (anniversary → prints → archives → cost → version-update) and 14-day cross-family cooldown are unchanged, so a user still sees at most one toast per fortnight. The "fire highest unseen milestone" logic in `_check_prints` / `_check_archives` / `_check_cost` is unchanged — a user already at 200 prints still gets `prints-100` first (they crossed it earlier in the timeline). The existing toast copy uses `{count}` / `{total}` interpolation in all 11 locales — no new i18n keys needed; "You've completed 10 prints with Bambuddy" reads as fluently as the 100 variant. **Tests.** `test_failed_prints_dont_count` and `test_fires_when_cost_sum_crosses_100` rebalanced (5 completed prints instead of 50; 5 prints × 21 cost-each instead of 30 × 3.5) so they still test "below the lowest threshold" semantics with the new lower bars. New `test_fires_at_lowest_threshold` pins `prints-10` as the new minimum trigger. `pytest -n 30 backend/tests/unit/test_sponsor_prompt_service.py backend/tests/integration/test_sponsor_prompt_api.py` green (25/25). `ruff check` clean. **Scope.** No DB migration. No new permission. No frontend change. The change is opt-in by virtue of the existing toast cooldown — installs that already saw a recent toast see no behaviour change; installs that never crossed the old 100-print bar become eligible the first time they pass 10 prints (subject to the 14-day cooldown after any other family fires first). + - **Autologin via SSO + disable local login (#1589, requested by @einstux)** — Two related additions for operators who run their own OIDC SSO and want exactly one auth path. **Global setting `local_login_enabled`** (default True, preserves pre-#1589 behaviour) — when False, `POST /api/v1/auth/login` rejects username + password credentials with HTTP 401 (same wording as wrong-password to avoid leaking "local disabled" to credential-stuffing tools), `POST /api/v1/auth/forgot-password` rejects with HTTP 403 (the reset wouldn't grant access anyway), and the LoginPage hides the credentials form + Forgot Password link, leaving only the OIDC provider buttons. **Env-var recovery path** `BAMBUDDY_LOCAL_LOGIN=true` (also accepts `1` / `yes`, case-insensitive) bypasses the gate on both routes and flips the reported `local_login_enabled` flag on `/auth/advanced-auth/status` back to True so the LoginPage matches what the route actually accepts — a server admin whose SSO provider is unreachable can recover the install with one env var, no DB editing. LDAP keeps its own `ldap_enabled` switch and is not affected by this gate — a delegated directory has its own policy and lockouts and is closer to SSO than to local credentials. **Per-OIDC-provider `is_autologin` flag** — when set on an enabled provider, the LoginPage redirects unauthenticated visitors directly to that provider's authorize URL on mount instead of rendering the login form. At most one provider can carry the flag at a time (app-layer invariant enforced in both create and update routes: setting it on one provider clears it on every other). **Two-layer fallback for autologin** — the LoginPage races `getOIDCAuthorizeUrl` against a 5-second timeout; on success the browser navigates to the IdP, on timeout or fetch error the redirect is aborted, the page renders normally, and a sticky amber banner explains "Autologin to failed, pick a provider". A bookmarkable `/login?fallback=local` query param always skips the autologin redirect — paired with the `BAMBUDDY_LOCAL_LOGIN=true` env-var on the server, this is the documented "SSO is broken, let me back in" path. **Two safety refusals on disabling local login**: settings PUT returns HTTP 400 ("no OIDC provider is enabled") when no enabled OIDC provider exists, and HTTP 400 ("you would lock yourself out") when the calling admin has no `UserOIDCLink` row. Either failure mode would otherwise lock everyone out of the install. **Backend.** `local_login_enabled: bool = True` added to `AppSettings` + `AppSettingsUpdate` schemas and to the `_BOOL_KEYS` allowlist in `routes/settings.py`. `OIDCProvider.is_autologin: bool` column via `_safe_execute(ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT ...)` — SQLite `DEFAULT 0`, Postgres `DEFAULT false` per the project's existing boolean-migration pattern. New `OIDCProviderResponse.is_autologin` field threaded through `from_attributes=True`. `_local_login_env_bypass()` reads at call time (not import time) so tests can monkeypatch the env between cases. `/auth/advanced-auth/status` extended with `local_login_enabled` and `autologin_provider_id` so the LoginPage decides UI in one query — `autologin_provider_id` filters on `is_enabled=True AND is_autologin=True` so disabling a provider stops the autologin redirect even if the flag stays set. **Frontend.** `LoginPage.tsx` adds the autologin `useEffect` (skips redirect when `?fallback=local` is in the URL, when an OIDC token is already in the fragment, or when an `oidc_error` query param is present from a previous round trip), the autologin-failed banner, and a "Local sign-in disabled" notice that replaces the form when the flag is off. `SettingsPage.tsx` exposes the `local_login_enabled` toggle in the OIDC tab card above the existing provider list; `OIDCProviderSettings.tsx` adds the per-provider Autologin toggle in the form's flags row. `AppSettings`, `AdvancedAuthStatus`, `OIDCProvider`, and `OIDCProviderCreate` TypeScript interfaces extended to match. **i18n.** 6 new keys (`login.autologinFailed`, `login.localDisabledNotice`, `settings.localLogin.disable`, `settings.localLogin.disableHint`, `settings.oidc.form.autologin`, `settings.oidc.form.autologinDesc`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5375 leaves per locale, no English fallback. **Tests.** 6 new integration cases in `test_local_login_gate.py`: login default allows local, login rejected when flag off and no env bypass (with generic 401 wording asserted), env-var bypasses the gate, forgot-password rejected when flag off, status surfaces both new fields, env bypass flips the reported flag back to True. Full nearby suites green: `test_auth_api.py` 44/44, `test_mfa_api.py + test_oidc_relogin.py + test_settings_ui_preferences.py` 159/159. Backend `ruff check` clean. Frontend `npm run build` clean. **Scope.** No new permission — the existing `SETTINGS_UPDATE` permission gates the toggle. The migration is a single `ADD COLUMN` per backend; the `local_login_enabled` setting lives in the existing settings key-value table and needs no migration. Default behaviour is unchanged: fresh installs and upgrades see no difference until an admin explicitly enables the toggle or sets a provider as autologin. - **Printer card AMS row: external tray height matches regular AMS slots** — On dual-nozzle printers (H2C / H2D) the External card carried an extra `Ext-L` / `Ext-R` caption underneath each tray to disambiguate which extruder it fed. That caption added one text line of vertical height to the External card only, so the entire bottom row of the printer card's AMS panel (External alongside AMS-C / HT-A) was visibly taller than the row above it (AMS-A / AMS-B). Fix: the L/R distinction now lives **inside** the slot's colour circle in place of the 1-based slot index (so the left external tray reads `L`, the right reads `R`), and the bottom caption is removed. Single-nozzle externals — a single tray with no left/right distinction — keep the `1` index. The `FilamentSlotCircle` `slotNumber` prop is widened from `number` to `number | string` to carry the L/R label; the two regular-AMS callsites that pass a numeric index keep working unchanged. The `Ext-L` / `Ext-R` strings are still used as the slot's "location" label in the filament hover card (so context is preserved when hovering for details) — just not as a separate caption on the visible row. Frontend `npm run build` clean. Existing 10 `FilamentSlotCircle` tests stay green (the new optional string accept-shape is backward-compatible). diff --git a/backend/app/services/sponsor_prompt.py b/backend/app/services/sponsor_prompt.py index 14c670765..ab9fa19bb 100644 --- a/backend/app/services/sponsor_prompt.py +++ b/backend/app/services/sponsor_prompt.py @@ -32,9 +32,9 @@ logger = logging.getLogger(__name__) COOLDOWN_DAYS = 14 -PRINT_MILESTONES = (100, 500, 1000, 2500, 5000) -COST_MILESTONES = (100, 500, 1000) -ARCHIVE_MILESTONES = (50, 250, 1000) +PRINT_MILESTONES = (10, 25, 100, 500, 1000, 2500, 5000) +COST_MILESTONES = (25, 50, 100, 500, 1000) +ARCHIVE_MILESTONES = (5, 10, 50, 250, 1000) ANNIVERSARY_YEARS = 1 diff --git a/backend/tests/unit/test_sponsor_prompt_service.py b/backend/tests/unit/test_sponsor_prompt_service.py index 0dfa807ae..e8c7f3f99 100644 --- a/backend/tests/unit/test_sponsor_prompt_service.py +++ b/backend/tests/unit/test_sponsor_prompt_service.py @@ -155,15 +155,24 @@ class TestPrintMilestones: assert trigger is not None assert trigger.milestone == "prints-100" + @pytest.mark.asyncio + async def test_fires_at_lowest_threshold(self, db_session: AsyncSession): + user = await _make_user(db_session) + await _add_completed_prints(db_session, user_id=user.id, count=10) + trigger = await service.evaluate(db_session, user.id) + assert trigger is not None + assert trigger.milestone == "prints-10" + assert trigger.threshold == 10 + @pytest.mark.asyncio async def test_failed_prints_dont_count(self, db_session: AsyncSession): user = await _make_user(db_session) - await _add_completed_prints(db_session, user_id=user.id, count=50) + await _add_completed_prints(db_session, user_id=user.id, count=5) for _ in range(60): db_session.add(PrintLogEntry(status="failed", created_by_id=user.id)) await db_session.flush() trigger = await service.evaluate(db_session, user.id) - # Only 50 completed → below 100 threshold → no print trigger. + # Only 5 completed → below 10 threshold → no print trigger. # Anniversary not reached either; no other counter populated. assert trigger is None @@ -181,10 +190,10 @@ class TestArchiveMilestones: class TestCostMilestones: @pytest.mark.asyncio async def test_fires_when_cost_sum_crosses_100(self, db_session: AsyncSession): - # Prints with cost = ~3.5 each, 30 prints → 105. + # 5 prints, cost ~21 each → 105 total. Below the 10-print threshold so + # the prints family stays silent and cost gets a chance. user = await _make_user(db_session) - await _add_completed_prints(db_session, user_id=user.id, count=30, cost_each=3.5) - # 30 < 100 prints, so prints-100 not eligible. cost = 105 ≥ 100 → fires. + await _add_completed_prints(db_session, user_id=user.id, count=5, cost_each=21.0) trigger = await service.evaluate(db_session, user.id) assert trigger is not None assert trigger.family == "cost"