Просмотр исходного кода

chore(frontend): dependency bumps

      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; release notes 3.4.1 -> 3.4.10
        reviewed — the three call sites (MakerworldPage,
        ProjectDetailPage, ProjectPageModal) use string-output
        sanitisation and are unaffected by 3.4.4's widened default
        allow-list)

      Build / lint / test tooling (transitive, dev-only):
      - @babel/core 7.29.0 -> 7.29.7 (via @vitejs/plugin-react and
        eslint-plugin-react-hooks)
      - vite 7.3.2 -> 7.3.5
      - markdown-it 14.1.1 -> 14.2.0 (via @tiptap/extension-link
        -> @tiptap/pm -> prosemirror-markdown; Bambuddy never calls
        markdown-it.render directly)
      - js-yaml 4.1.1 -> 4.2.0 (via eslint)
      - form-data 4.0.5 -> 4.0.6 (via jsdom)
      - ws 8.20.1 -> 8.21.0 (via jsdom)
maziggy 2 месяцев назад
Родитель
Сommit
000af6830b

Разница между файлами не показана из-за своего большого размера
+ 4 - 0
CHANGELOG.md


+ 2 - 1
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

+ 4 - 3
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(

+ 4 - 4
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(

+ 1 - 0
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":

+ 35 - 1
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

+ 15 - 0
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

+ 229 - 0
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

+ 128 - 122
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==",
-      "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",
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+      "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+      "dev": true,
+      "dependencies": {
+        "@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",

+ 1 - 1
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",

+ 2 - 0
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

+ 11 - 0
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: {

+ 12 - 0
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',

+ 11 - 0
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: {

+ 11 - 0
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: {

+ 11 - 0
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: {

+ 11 - 0
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: {

+ 11 - 0
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 제공자를 설정하세요.',

+ 11 - 0
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: {

+ 11 - 0
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: {

+ 11 - 0
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: {

+ 11 - 0
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: {

+ 70 - 2
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 && (
             <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
-              {/* Left Column: Current User + User List */}
+              {/* Left Column: Session Policy + Current User + User List */}
               <div className="space-y-3">
+                {/* Session Policy (#1706) — admin-set ceiling for user session lifetime */}
+                <Card id="card-session-policy">
+                  <CardHeader>
+                    <h3 className="text-lg font-semibold text-white flex items-center gap-2">
+                      <Lock className="w-5 h-5 text-bambu-green" />
+                      {t('settings.sessionPolicy.title')}
+                    </h3>
+                  </CardHeader>
+                  <CardContent>
+                    <p className="text-sm text-bambu-gray mb-4">
+                      {t('settings.sessionPolicy.description')}
+                    </p>
+                    <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
+                      {[
+                        { hours: 24, labelKey: 'settings.sessionPolicy.preset24h' },
+                        { hours: 168, labelKey: 'settings.sessionPolicy.preset7d' },
+                        { hours: 720, labelKey: 'settings.sessionPolicy.preset30d' },
+                      ].map((preset) => {
+                        const current = localSettings?.session_max_hours ?? 24;
+                        const isActive = current === preset.hours;
+                        return (
+                          <button
+                            key={preset.hours}
+                            type="button"
+                            onClick={() => updateSetting('session_max_hours', preset.hours)}
+                            disabled={authEnabled && !hasPermission('settings:update')}
+                            className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
+                              isActive
+                                ? 'bg-bambu-green text-white'
+                                : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark'
+                            } disabled:opacity-50 disabled:cursor-not-allowed`}
+                          >
+                            {t(preset.labelKey)}
+                          </button>
+                        );
+                      })}
+                      <div className="flex items-center gap-1">
+                        <input
+                          type="number"
+                          min={1}
+                          max={720}
+                          value={localSettings?.session_max_hours ?? 24}
+                          onChange={(e) => {
+                            const raw = parseInt(e.target.value, 10);
+                            if (Number.isNaN(raw)) return;
+                            updateSetting('session_max_hours', Math.max(1, Math.min(720, raw)));
+                          }}
+                          disabled={authEnabled && !hasPermission('settings:update')}
+                          aria-label={t('settings.sessionPolicy.customHoursLabel')}
+                          className="w-20 px-2 py-2 bg-bambu-dark-tertiary text-white text-sm rounded-lg border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none disabled:opacity-50"
+                        />
+                        <span className="text-sm text-bambu-gray">{t('settings.sessionPolicy.hoursSuffix')}</span>
+                      </div>
+                    </div>
+                    {(localSettings?.session_max_hours ?? 24) > 24 && (
+                      <div className="flex items-start gap-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
+                        <AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" />
+                        <p className="text-xs text-yellow-200">
+                          {t('settings.sessionPolicy.warning')}
+                        </p>
+                      </div>
+                    )}
+                  </CardContent>
+                </Card>
+
                 {/* Current User Card */}
                 {user && (
                   <Card>

Некоторые файлы не были показаны из-за большого количества измененных файлов