diff --git a/CHANGELOG.md b/CHANGELOG.md index 150990f78..8f8d9917a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4.7] - 2026-06-14 +### Security +- **Frontend dependency bumps** — Routine version updates across the runtime, build, and test dependency surface. **Runtime:** `dompurify` 3.4.0 → 3.4.10. `package.json` floor raised from `^3.4.0` to `^3.4.10` so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list with `selectedcontent` + `command` + `commandfor` (all valid modern HTML, harmless for our two default-allow-list call sites), and `ProjectPageModal` is unaffected anyway because it sets an explicit `ALLOWED_TAGS` / `ALLOWED_ATTR` whitelist. **Build / lint / test tooling (transitive, dev-only):** `@babel/core` 7.29.0 → 7.29.7 (pulled by `@vitejs/plugin-react` and `eslint-plugin-react-hooks`), `vite` 7.3.2 → 7.3.5, `markdown-it` 14.1.1 → 14.2.0 (pulled by `@tiptap/extension-link` → `@tiptap/pm` → `prosemirror-markdown`; Bambuddy never calls `markdown-it.render` directly so the change is transparent), `js-yaml` 4.1.1 → 4.2.0 (pulled by `eslint`), `form-data` 4.0.5 → 4.0.6 + `ws` 8.20.1 → 8.21.0 (both pulled by `jsdom` in the test runtime). All bumps inside existing semver ranges except `dompurify`. No source changes required. + +### Added +- **Admin-configurable session lifetime (#1706, reported by @AD3DStuff)** — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. **New setting:** `session_max_hours` under Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." **Backend wiring:** new `resolve_session_max_minutes(db)` helper in `backend/app/core/auth.py` reads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain `/auth/login`, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depth `SESSION_MAX_HOURS_HARD_CEILING = 720` clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. **What this does NOT change:** the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (`MAX_TOKEN_LIFETIME_DAYS`), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. **Tests:** 15 new cases in `backend/tests/integration/test_session_policy.py` split across three classes. `TestResolveSessionMaxMinutes` pins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp. `TestLoginRespectsSessionPolicy` decodes the JWT `exp` claim end-to-end and asserts the token returned by `/auth/login` honours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases. `TestSettingsAPIExposesSessionMaxHours` round-trips the field through `/settings/` (default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. **i18n:** 8 new keys in `settings.sessionPolicy.*` namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean; `npm run build` clean; ruff clean. + ### Fixed - **SpoolBuddy inventory search now matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink)** — The reporter found that typing a numeric spool ID into SpoolBuddy → Inventory's search box returned no results, even though the same query in Bambuddy's main Inventory page worked. Root cause: `frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx:147-155` reimplemented the search filter inline and only matched `material`, `subtype`, `brand`, `color_name`, and `note`. The main Inventory page delegates to the shared `filterSpoolsByQuery` helper in `frontend/src/utils/inventorySearch.ts:7`, which additionally matches `String(spool.id)`, `slicer_filament_name`, and `storage_location`. SpoolBuddy had diverged. **Fix:** replace the inline filter with a single call to `filterSpoolsByQuery(list, searchQuery.trim())`. Both inventory modes (internal via `getSpools`, Spoolman via `getSpoolmanInventorySpools`) return the same `InventorySpool` shape, so this covers both paths in one drop. SpoolBuddy now matches Bambuddy's search behaviour across all eight fields. **Tests:** new `SpoolBuddyInventorySearch.test.ts` with 4 cases pinning the parity — exact spool ID match, partial spool ID match, the five pre-fix fields still match, and the three newly-included fields (storage_location, slicer_filament_name, plus implicit id) match. Existing `inventorySearch.test.ts` ID matching test (#1336) still green. ESLint clean; `npm run build` clean. No backend change, no i18n, no new permission. diff --git a/README.md b/README.md index 1aeb9cad7..4eba2ac96 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,9 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R ### ⏰ Scheduling & Automation - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button) -- Print queue with drag-and-drop and timeline schedule view +- Print queue with three tabs (Queue / History / Timeline), multi-select drag-and-drop, batch grouping, and a Gantt-style timeline - Multi-printer selection (send to multiple printers at once) +- Batch grouping — multi-plate prints auto-group into a collapsible row; any 2+ selected items can be grouped manually via "Group as batch", with ungroup on the batch parent - Batch print quantity (print multiple copies — set quantity in the print/schedule dialog, first copy prints immediately, rest are queued) - Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs) - Configurable default print options (bed levelling, flow/vibration calibration, first layer inspection, timelapse) in Settings → Workflow diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index e473a7873..ac07e08fc 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -15,7 +15,6 @@ from sqlalchemy.orm import selectinload from backend.app.api.routes.settings import get_external_login_url from backend.app.core.auth import ( - ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY, Permission, @@ -31,6 +30,7 @@ from backend.app.core.auth import ( get_user_by_email, get_user_by_username, is_jti_revoked, + resolve_session_max_minutes, revoke_jti, security, ) @@ -495,8 +495,9 @@ async def login(raw_request: Request, request: LoginRequest, response: Response, two_fa_methods=methods, ) - # No 2FA — issue full token immediately - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + # No 2FA — issue full token immediately. Session lifetime honours the + # admin-configurable ceiling (#1706); resolver clamps to [1h, 720h]. + access_token_expires = timedelta(minutes=await resolve_session_max_minutes(db)) access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires) return LoginResponse( diff --git a/backend/app/api/routes/mfa.py b/backend/app/api/routes/mfa.py index 1fb6fecca..83bda734b 100644 --- a/backend/app/api/routes/mfa.py +++ b/backend/app/api/routes/mfa.py @@ -41,13 +41,13 @@ from sqlalchemy.orm import selectinload, undefer from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url from backend.app.api.routes.settings import get_setting, set_setting from backend.app.core.auth import ( - ACCESS_TOKEN_EXPIRE_MINUTES, RequirePermissionIfAuthEnabled, create_access_token, get_current_active_user, get_user_by_email, get_user_by_username, is_auth_enabled, + resolve_session_max_minutes, verify_password, ) from backend.app.core.database import get_db @@ -1242,7 +1242,7 @@ async def verify_2fa( access_token = create_access_token( data={"sub": user.username}, - expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)), ) result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups))) user = result.scalar_one() @@ -1258,7 +1258,7 @@ async def verify_2fa( access_token = create_access_token( data={"sub": user.username}, - expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)), ) # Reload with groups for permission calculation @@ -2146,7 +2146,7 @@ async def oidc_exchange( access_token = create_access_token( data={"sub": user.username}, - expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)), ) return LoginResponse( diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 201646707..87241e41b 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -159,6 +159,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) - "stagger_group_size", "stagger_interval_minutes", "forecast_global_lead_time_days", + "session_max_hours", ]: settings_dict[setting.key] = int(setting.value) elif setting.key == "default_printer_id": diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 3939efe92..c02d9207a 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -421,10 +421,42 @@ def _get_jwt_secret() -> str: SECRET_KEY = _get_jwt_secret() ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours (M-2: reduced from 7 days) +# Hard ceiling for the admin-configurable session policy (#1706). 30 days +# matches the Pydantic le=720 on AppSettings.session_max_hours; defense in +# depth so a tampered settings row can't request an absurd lifetime. +SESSION_MAX_HOURS_HARD_CEILING = 720 # HTTP Bearer token security = HTTPBearer(auto_error=False) + +async def resolve_session_max_minutes(db: AsyncSession) -> int: + """Return the session-lifetime ceiling (minutes) honoured by login routes. + + Reads ``session_max_hours`` from the settings table (#1706), clamps to + [1h, 720h], and falls back to the audit-default 24h if the row is + missing, blank, or unparseable. + + DB errors are NOT caught here — login is already in a DB transaction and + a broken DB must abort the login rather than silently extend or shrink + the session lifetime. + """ + default_minutes = ACCESS_TOKEN_EXPIRE_MINUTES + result = await db.execute(select(Settings).where(Settings.key == "session_max_hours")) + row = result.scalar_one_or_none() + if row is None or not row.value: + return default_minutes + try: + hours = int(row.value) + except (TypeError, ValueError): + return default_minutes + if hours < 1: + return default_minutes + if hours > SESSION_MAX_HOURS_HARD_CEILING: + hours = SESSION_MAX_HOURS_HARD_CEILING + return hours * 60 + + # --- Slicer download tokens --- # Short-lived, single-use tokens for slicer protocol handlers that can't send # auth headers. Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD) @@ -649,7 +681,9 @@ def _is_token_fresh(iat: int | float | None, user: User) -> bool: Used to invalidate all sessions after a password reset/change (M-R7-B). All tokens without an iat claim are unconditionally rejected — every token issued by this server carries iat, so absence means the token is forged or - from a pre-iat code path whose max TTL (24 h) has long since expired. + from a pre-iat code path whose max TTL at the time (24 h) has long since + expired. The post-#1706 admin-set ceiling does not relax this — an iat-less + token still cannot have been issued by current code. """ if iat is None: return False diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 249871922..319363b0b 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -240,6 +240,20 @@ class AppSettings(BaseModel): description="Low stock threshold percentage (%) for inventory filtering and display", ) + # Session policy (#1706) — admin-set ceiling for user session lifetime. + # Default 24h preserves the M-2 audit reduction from 7 days. Max 720h + # (30 days) bounds blast radius if an admin chooses a long session. + session_max_hours: int = Field( + default=24, + ge=1, + le=720, + description=( + "Maximum session lifetime in hours for user logins (default 24, max 720). " + "Applies to new logins only; already-issued tokens keep their original expiry. " + "Longer sessions reduce automatic logout protection." + ), + ) + # User email notifications (requires Advanced Authentication) user_notifications_enabled: bool = Field( default=True, @@ -414,6 +428,7 @@ class AppSettingsUpdate(BaseModel): prometheus_enabled: bool | None = None prometheus_token: str | None = None low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9) + session_max_hours: int | None = Field(default=None, ge=1, le=720) user_notifications_enabled: bool | None = None default_bed_levelling: bool | None = None default_flow_cali: bool | None = None diff --git a/backend/tests/integration/test_session_policy.py b/backend/tests/integration/test_session_policy.py new file mode 100644 index 000000000..d55a9ad8a --- /dev/null +++ b/backend/tests/integration/test_session_policy.py @@ -0,0 +1,229 @@ +"""Integration tests for the admin-set session-lifetime ceiling (#1706). + +Covers the four token-issuance sites that read ``session_max_hours``: +plain login, 2FA backup-code login, 2FA TOTP/email login, OIDC login. +Only the first is exercised end-to-end via ``async_client``; the helper +``resolve_session_max_minutes`` itself is unit-tested below so the MFA +and OIDC paths inherit the same clamping behaviour by construction. +""" + +import time + +import jwt +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.auth import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + ALGORITHM, + SECRET_KEY, + SESSION_MAX_HOURS_HARD_CEILING, + resolve_session_max_minutes, +) +from backend.app.models.settings import Settings + + +async def _set_session_max_hours(db: AsyncSession, value: str | None) -> None: + """Upsert the session_max_hours setting row (value=None deletes it).""" + result = await db.execute(select(Settings).where(Settings.key == "session_max_hours")) + existing = result.scalar_one_or_none() + if value is None: + if existing is not None: + await db.delete(existing) + await db.commit() + return + if existing is None: + db.add(Settings(key="session_max_hours", value=value)) + else: + existing.value = value + await db.commit() + + +class TestResolveSessionMaxMinutes: + """Unit-style tests for the clamping resolver.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_missing_row_returns_24h_default(self, db_session: AsyncSession): + await _set_session_max_hours(db_session, None) + assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES + assert ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_empty_string_returns_24h_default(self, db_session: AsyncSession): + await _set_session_max_hours(db_session, "") + assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_unparseable_value_returns_24h_default(self, db_session: AsyncSession): + await _set_session_max_hours(db_session, "not-a-number") + assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_zero_or_negative_returns_24h_default(self, db_session: AsyncSession): + await _set_session_max_hours(db_session, "0") + assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES + await _set_session_max_hours(db_session, "-5") + assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_one_hour_minimum(self, db_session: AsyncSession): + await _set_session_max_hours(db_session, "1") + assert await resolve_session_max_minutes(db_session) == 60 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_seven_days_passes_through(self, db_session: AsyncSession): + await _set_session_max_hours(db_session, "168") + assert await resolve_session_max_minutes(db_session) == 168 * 60 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_thirty_days_passes_through(self, db_session: AsyncSession): + await _set_session_max_hours(db_session, str(SESSION_MAX_HOURS_HARD_CEILING)) + assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_above_ceiling_is_clamped_to_30_days(self, db_session: AsyncSession): + """Defense-in-depth: a tampered settings row above 720h must be clamped.""" + await _set_session_max_hours(db_session, "99999") + assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60 + + +class TestLoginRespectsSessionPolicy: + """The /auth/login route must honour the resolved ceiling.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_login_uses_default_24h_when_unset(self, async_client: AsyncClient, db_session: AsyncSession): + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "sessiontest1", + "admin_password": "SessionPass1!", + }, + ) + await _set_session_max_hours(db_session, None) + + before = int(time.time()) + response = await async_client.post( + "/api/v1/auth/login", + json={"username": "sessiontest1", "password": "SessionPass1!"}, + ) + after = int(time.time()) + + assert response.status_code == 200 + token = response.json()["access_token"] + decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + # exp should be ~24h ahead. Allow generous bounds for clock drift. + expected_min = before + 24 * 3600 - 60 + expected_max = after + 24 * 3600 + 60 + assert expected_min <= decoded["exp"] <= expected_max + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_login_uses_configured_7d_ceiling(self, async_client: AsyncClient, db_session: AsyncSession): + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "sessiontest2", + "admin_password": "SessionPass2!", + }, + ) + await _set_session_max_hours(db_session, "168") # 7 days + + before = int(time.time()) + response = await async_client.post( + "/api/v1/auth/login", + json={"username": "sessiontest2", "password": "SessionPass2!"}, + ) + after = int(time.time()) + + assert response.status_code == 200 + token = response.json()["access_token"] + decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + expected_min = before + 168 * 3600 - 60 + expected_max = after + 168 * 3600 + 60 + assert expected_min <= decoded["exp"] <= expected_max + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_login_clamps_above_ceiling(self, async_client: AsyncClient, db_session: AsyncSession): + """A settings row above the 720h ceiling must be clamped at login time.""" + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "sessiontest3", + "admin_password": "SessionPass3!", + }, + ) + await _set_session_max_hours(db_session, "5000") # would be ~208 days + + before = int(time.time()) + response = await async_client.post( + "/api/v1/auth/login", + json={"username": "sessiontest3", "password": "SessionPass3!"}, + ) + after = int(time.time()) + + assert response.status_code == 200 + token = response.json()["access_token"] + decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + # Clamped to 30 days, not 5000 hours. + expected_min = before + SESSION_MAX_HOURS_HARD_CEILING * 3600 - 60 + expected_max = after + SESSION_MAX_HOURS_HARD_CEILING * 3600 + 60 + assert expected_min <= decoded["exp"] <= expected_max + + +class TestSettingsAPIExposesSessionMaxHours: + """The /settings API must round-trip session_max_hours as an int.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_default_is_24(self, async_client: AsyncClient, db_session: AsyncSession): + await _set_session_max_hours(db_session, None) + response = await async_client.get("/api/v1/settings/") + assert response.status_code == 200 + assert response.json()["session_max_hours"] == 24 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_accepts_valid_value(self, async_client: AsyncClient, db_session: AsyncSession): + response = await async_client.patch( + "/api/v1/settings/", + json={"session_max_hours": 168}, + ) + assert response.status_code == 200 + assert response.json()["session_max_hours"] == 168 + # Persisted as the int's string form so the resolver round-trips. + result = await db_session.execute(select(Settings).where(Settings.key == "session_max_hours")) + row = result.scalar_one() + assert row.value == "168" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_rejects_zero(self, async_client: AsyncClient): + response = await async_client.patch( + "/api/v1/settings/", + json={"session_max_hours": 0}, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_rejects_above_ceiling(self, async_client: AsyncClient): + response = await async_client.patch( + "/api/v1/settings/", + json={"session_max_hours": SESSION_MAX_HOURS_HARD_CEILING + 1}, + ) + assert response.status_code == 422 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a893a06c2..374ec972b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,7 +22,7 @@ "@tiptap/react": "^3.11.1", "@tiptap/starter-kit": "^3.11.1", "@types/three": "^0.181.0", - "dompurify": "^3.4.0", + "dompurify": "^3.4.10", "gcode-preview": "^2.18.0", "i18next": "25.6.3", "i18next-browser-languagedetector": "^8.2.0", @@ -105,13 +105,12 @@ "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -120,31 +119,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -161,14 +158,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -178,14 +174,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -195,39 +190,36 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -265,24 +257,22 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -345,33 +335,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -4116,9 +4104,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", - "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", + "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -4613,17 +4601,16 @@ "dev": true }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, - "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4839,11 +4826,10 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, - "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -5179,11 +5165,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "dependencies": { "argparse": "^2.0.1" }, @@ -5237,7 +5232,6 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, - "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -5591,10 +5585,19 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "license": "MIT", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "dependencies": { "uc.micro": "^2.0.0" } @@ -5633,7 +5636,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "ISC", "dependencies": { "yallist": "^3.0.2" } @@ -5709,14 +5711,23 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", - "license": "MIT", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -6739,7 +6750,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -7114,8 +7124,7 @@ "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" }, "node_modules/undici-types": { "version": "7.16.0", @@ -7212,11 +7221,10 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, - "license": "MIT", "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7544,11 +7552,10 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, - "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -7596,8 +7603,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/yargs": { "version": "17.7.2", diff --git a/frontend/package.json b/frontend/package.json index 8131a4725..4fb9a15d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ "@tiptap/react": "^3.11.1", "@tiptap/starter-kit": "^3.11.1", "@types/three": "^0.181.0", - "dompurify": "^3.4.0", + "dompurify": "^3.4.10", "gcode-preview": "^2.18.0", "i18next": "25.6.3", "i18next-browser-languagedetector": "^8.2.0", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1fcea2a8b..1c09481d3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1155,6 +1155,8 @@ export interface AppSettings { bed_cooled_threshold: number; // Inventory low stock threshold low_stock_threshold: number; + // Session policy (#1706) — admin-set ceiling, hours, [1, 720] + session_max_hours: number; // User email notifications toggle user_notifications_enabled: boolean; // Default print options diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index fbff82e9d..93774582a 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2381,6 +2381,17 @@ export default { linkedAccountsDesc: 'Diese externen Identitätsanbieter sind mit deinem Konto verknüpft.', oidcUnlinked: 'Konto getrennt.', }, + // Session Policy (#1706) + sessionPolicy: { + title: 'Sitzungsrichtlinie', + description: 'Maximale Sitzungsdauer für neue Benutzeranmeldungen. Bereits ausgegebene Token behalten ihren ursprünglichen Ablauf.', + preset24h: '24 Stunden', + preset7d: '7 Tage', + preset30d: '30 Tage', + customHoursLabel: 'Individuelle Sitzungsdauer in Stunden', + hoursSuffix: 'Stunden', + warning: 'Längere Sitzungen reduzieren den automatischen Abmeldeschutz. Nur für vertrauenswürdige Einzelnutzer-Installationen empfohlen.', + }, // OIDC provider settings oidc: { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ee56f186d..88f6cb400 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2392,6 +2392,18 @@ export default { oidcUnlinked: 'Account unlinked.', }, + // Session Policy (#1706) — admin-configurable session lifetime ceiling. + sessionPolicy: { + title: 'Session Policy', + description: 'Maximum session lifetime for new user logins. Already-issued tokens keep their original expiry.', + preset24h: '24 hours', + preset7d: '7 days', + preset30d: '30 days', + customHoursLabel: 'Custom session lifetime in hours', + hoursSuffix: 'hours', + warning: 'Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments.', + }, + // OIDC provider settings oidc: { title: 'SSO / OIDC Providers', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 65a3aa4d4..0ef2a1402 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -2384,6 +2384,17 @@ export default { linkedAccountsDesc: 'Estos proveedores de identidad externos están vinculados a su cuenta.', oidcUnlinked: 'Cuenta desvinculada.', }, + // Session Policy (#1706) + sessionPolicy: { + title: 'Política de sesión', + description: 'Duración máxima de sesión para nuevos inicios de sesión. Los tokens ya emitidos conservan su caducidad original.', + preset24h: '24 horas', + preset7d: '7 días', + preset30d: '30 días', + customHoursLabel: 'Duración de sesión personalizada en horas', + hoursSuffix: 'horas', + warning: 'Las sesiones más largas reducen la protección de cierre automático. Recomendado solo para implementaciones de usuario único en entornos de confianza.', + }, // OIDC provider settings oidc: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 2092bedfb..01d07b715 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2324,6 +2324,17 @@ export default { linkedAccountsDesc: 'Ces fournisseurs d\'identité externes sont liés à votre compte.', oidcUnlinked: 'Compte dissocié.', }, + // Session Policy (#1706) + sessionPolicy: { + title: 'Politique de session', + description: 'Durée maximale des sessions pour les nouvelles connexions utilisateur. Les jetons déjà émis conservent leur expiration d\'origine.', + preset24h: '24 heures', + preset7d: '7 jours', + preset30d: '30 jours', + customHoursLabel: 'Durée de session personnalisée en heures', + hoursSuffix: 'heures', + warning: 'Les sessions plus longues réduisent la protection de déconnexion automatique. Recommandé uniquement pour les déploiements mono-utilisateur de confiance.', + }, // OIDC provider settings oidc: { diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index d4170ff47..075ba5173 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2323,6 +2323,17 @@ export default { linkedAccountsDesc: 'Questi provider di identità esterni sono collegati al tuo account.', oidcUnlinked: 'Account scollegato.', }, + // Session Policy (#1706) + sessionPolicy: { + title: 'Criterio di sessione', + description: 'Durata massima della sessione per i nuovi accessi utente. I token già emessi mantengono la scadenza originale.', + preset24h: '24 ore', + preset7d: '7 giorni', + preset30d: '30 giorni', + customHoursLabel: 'Durata personalizzata della sessione in ore', + hoursSuffix: 'ore', + warning: 'Le sessioni più lunghe riducono la protezione di disconnessione automatica. Consigliato solo per installazioni mono-utente attendibili.', + }, // OIDC provider settings oidc: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 320a6ed7a..852b3a75d 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2380,6 +2380,17 @@ export default { linkedAccountsDesc: 'これらの外部IDプロバイダーがあなたのアカウントにリンクされています。', oidcUnlinked: 'アカウントのリンクを解除しました。', }, + // Session Policy (#1706) + sessionPolicy: { + title: 'セッションポリシー', + description: '新しいユーザーログインの最大セッション有効期間。すでに発行されたトークンは元の有効期限を保持します。', + preset24h: '24時間', + preset7d: '7日', + preset30d: '30日', + customHoursLabel: 'カスタムセッション有効期間(時間)', + hoursSuffix: '時間', + warning: '長いセッションは自動ログアウト保護を弱めます。信頼できる単一ユーザー環境でのみ推奨されます。', + }, // OIDC provider settings oidc: { diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 146458628..e727331ac 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -2238,6 +2238,17 @@ export default { linkedAccountsDesc: '이 외부 ID 제공자가 계정에 연결되어 있습니다.', oidcUnlinked: '계정 연결이 해제되었습니다.' }, + // 세션 정책 (#1706) + sessionPolicy: { + title: '세션 정책', + description: '신규 사용자 로그인의 최대 세션 수명입니다. 이미 발급된 토큰은 원래 만료 시간을 유지합니다.', + preset24h: '24시간', + preset7d: '7일', + preset30d: '30일', + customHoursLabel: '사용자 지정 세션 수명(시간)', + hoursSuffix: '시간', + warning: '세션이 길어질수록 자동 로그아웃 보호가 약해집니다. 신뢰할 수 있는 단일 사용자 배포에서만 권장됩니다.' + }, oidc: { title: 'SSO / OIDC 제공자', desc: '외부 ID 제공자를 통해 싱글 사인온을 허용하도록 OpenID Connect 제공자를 설정하세요.', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 9370b7178..2d500a29f 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2323,6 +2323,17 @@ export default { linkedAccountsDesc: 'Estes provedores de identidade externos estão vinculados à sua conta.', oidcUnlinked: 'Conta desvinculada.', }, + // Session Policy (#1706) + sessionPolicy: { + title: 'Política de sessão', + description: 'Duração máxima da sessão para novos logins de usuário. Tokens já emitidos mantêm sua expiração original.', + preset24h: '24 horas', + preset7d: '7 dias', + preset30d: '30 dias', + customHoursLabel: 'Duração personalizada da sessão em horas', + hoursSuffix: 'horas', + warning: 'Sessões mais longas reduzem a proteção de logout automático. Recomendado apenas para implantações de usuário único confiáveis.', + }, // OIDC provider settings oidc: { diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 0b847402b..e9804740b 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -2384,6 +2384,17 @@ export default { linkedAccountsDesc: 'Bu harici kimlik sağlayıcıları hesabınıza bağlıdır.', oidcUnlinked: 'Hesap bağlantısı kaldırıldı.', }, + // Session Policy (#1706) + sessionPolicy: { + title: 'Oturum Politikası', + description: 'Yeni kullanıcı girişleri için maksimum oturum süresi. Daha önce verilmiş belirteçler özgün son kullanma tarihlerini korur.', + preset24h: '24 saat', + preset7d: '7 gün', + preset30d: '30 gün', + customHoursLabel: 'Özel oturum süresi (saat)', + hoursSuffix: 'saat', + warning: 'Daha uzun oturumlar otomatik oturum kapatma korumasını azaltır. Yalnızca güvenilir tek kullanıcılı dağıtımlar için önerilir.', + }, // OIDC sağlayıcı ayarları oidc: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 75fc1bdf0..aa9758d13 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2368,6 +2368,17 @@ export default { linkedAccountsDesc: '以下外部身份提供商已与您的账户关联。', oidcUnlinked: '账户已解除关联。', }, + // Session Policy (#1706) + sessionPolicy: { + title: '会话策略', + description: '新用户登录的最长会话有效期。已颁发的令牌保留其原有的过期时间。', + preset24h: '24 小时', + preset7d: '7 天', + preset30d: '30 天', + customHoursLabel: '自定义会话有效期(小时)', + hoursSuffix: '小时', + warning: '更长的会话会减弱自动注销保护。仅建议在受信任的单用户部署中使用。', + }, // OIDC provider settings oidc: { diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 47e37fb5e..0f357e91a 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -2368,6 +2368,17 @@ export default { linkedAccountsDesc: '以下外部身份提供者已與您的帳戶連結。', oidcUnlinked: '帳戶已解除連結。', }, + // Session Policy (#1706) + sessionPolicy: { + title: '工作階段政策', + description: '新使用者登入的最長工作階段有效期。已發行的權杖會保留其原有的到期時間。', + preset24h: '24 小時', + preset7d: '7 天', + preset30d: '30 天', + customHoursLabel: '自訂工作階段有效期(小時)', + hoursSuffix: '小時', + warning: '較長的工作階段會削弱自動登出保護。僅建議在受信任的單一使用者部署中使用。', + }, // OIDC provider settings oidc: { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 58ac9905b..3aca18e93 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -87,6 +87,7 @@ registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy' registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' }); registerSettingsSearch({ labelKey: 'settings.users', tab: 'users', subTab: 'users', keywords: 'users accounts list', anchor: 'card-users' }); registerSettingsSearch({ labelKey: 'settings.groups', tab: 'users', subTab: 'users', keywords: 'groups roles permissions administrators operators viewers', anchor: 'card-groups' }); +registerSettingsSearch({ labelKey: 'settings.sessionPolicy.title', labelFallback: 'Session Policy', tab: 'users', subTab: 'users', keywords: 'session timeout expiry logout remember me jwt token lifetime', anchor: 'card-session-policy' }); registerSettingsSearch({ labelKey: 'settings.email.smtpSettings', labelFallback: 'SMTP Configuration', tab: 'users', subTab: 'email', keywords: 'smtp email send server port password auth starttls ssl', anchor: 'card-smtp' }); registerSettingsSearch({ labelKey: 'settings.ldap.title', labelFallback: 'LDAP Authentication', tab: 'users', subTab: 'ldap', keywords: 'ldap active directory ad authentication bind dn search base group mapping', anchor: 'card-ldap' }); registerSettingsSearch({ labelKey: 'settings.tabs.backup', tab: 'backup', keywords: 'backup github restore download cloud sync profiles archives', anchor: 'card-backup' }); @@ -1012,7 +1013,8 @@ export function SettingsPage() { (settings.default_nozzle_offset_cali ?? true) !== (localSettings.default_nozzle_offset_cali ?? true) || (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) || (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) || - (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false); + (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) || + (settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24); if (!hasChanges) { return; @@ -1099,6 +1101,7 @@ export function SettingsPage() { stagger_group_size: localSettings.stagger_group_size, stagger_interval_minutes: localSettings.stagger_interval_minutes, require_plate_clear: localSettings.require_plate_clear, + session_max_hours: localSettings.session_max_hours, }; updateMutation.mutate(settingsToSave); }, 500); @@ -5117,8 +5120,73 @@ export function SettingsPage() { {authEnabled && (
+ {t('settings.sessionPolicy.description')} +
++ {t('settings.sessionPolicy.warning')} +
+