mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
feat(auth): admin-configurable session lifetime ceiling (#1706)
The 24h session cap from the M-2 audit finding was hard-coded, so the
"Remember Me" checkbox could only control storage location, never
duration. Add session_max_hours setting (default 24, max 720) honoured
at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
backup, OIDC.
- backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
that clamps to [1h, 720h] and falls back to 24h on missing/blank/
unparseable. DB errors propagate — the login transaction must abort
on a broken DB rather than silently extend or shrink the lifetime.
- backend/app/api/routes/auth.py, mfa.py: all four sites read the
resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
- backend/app/schemas/settings.py, routes/settings.py: schema field
with ge=1 le=720 + int coercion in _build_settings_response.
- frontend/src/pages/SettingsPage.tsx: half-width card at top of
Settings -> Users left column with 24h/7d/30d presets, custom input,
and a yellow warning when value > 24h.
- frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
- backend/tests/integration/test_session_policy.py: 15 tests across
resolver clamping, login JWT exp end-to-end, settings API round-trip.
Already-issued tokens keep their original expiry; the new setting only
affects future logins.
This commit is contained in:
parent
b97f25d819
commit
2940fbdcf7
22 changed files with 544 additions and 69 deletions
|
|
@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
|
||||
## [0.2.5b1] - Unreleased
|
||||
|
||||
### 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
backend/tests/integration/test_session_policy.py
Normal file
229
backend/tests/integration/test_session_policy.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 제공자를 설정하세요.',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-lB37rzBj.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DznM9swC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JNXvMxhG.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue