diff --git a/README.md b/README.md index 5bacf80..4cf0f16 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**). - **Single Sign-on (optional)** über OpenID Connect, z. B. gegen ein selbst gehostetes [Authentik](https://goauthentik.io). Der Passwort-Login bleibt daneben bestehen – siehe [Single Sign-on](#single-sign-on-optional). +- **Sitzungsdauer einstellbar** (Einstellungen → Anmeldung): 7 Tage bis 1 Jahr + oder unbegrenzt, für Website und App gemeinsam. Die Frist zählt ab dem letzten + Besuch, nicht ab dem Anmelden. ### In der iOS-App zusätzlich - **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat diff --git a/backend/app/config.py b/backend/app/config.py index 80de033..c13c057 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,10 @@ class Settings(BaseSettings): # Auth / JWT jwt_secret: str = "change-me-in-production" jwt_algorithm: str = "HS256" - jwt_expire_minutes: int = 60 * 24 * 7 # 7 days + # Rueckfallwert fuer die Sitzungsdauer. Im Betrieb entscheidet die + # Einstellung "session_days" (Verwaltung -> Einstellungen); dieser Wert gilt + # nur, solange dort nichts gesetzt ist. + jwt_expire_minutes: int = 60 * 24 * 30 # 30 Tage # First admin, created on startup if no users exist admin_username: str = "admin" diff --git a/backend/app/deps.py b/backend/app/deps.py index 318af19..d518e79 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -17,7 +17,7 @@ _credentials_exc = HTTPException( ) -def _nutzer_aus_payload(db: Session, payload: dict) -> User | None: +def nutzer_aus_payload(db: Session, payload: dict) -> User | None: """Loest die Sitzung auf – bevorzugt ueber die Benutzer-ID. Der Benutzername ist Anzeige und darf sich aendern; die ID nicht. Deshalb @@ -44,7 +44,7 @@ def get_current_user( """Akzeptiert ein JWT aus dem Login oder ein langlebiges API-Token.""" payload = decode_access_token(token) if payload is not None: - user = _nutzer_aus_payload(db, payload) + user = nutzer_aus_payload(db, payload) if user is None: raise _credentials_exc return user diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index ff59cf3..1b9f191 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -3,10 +3,11 @@ from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from ..database import get_db -from ..deps import get_current_user +from ..deps import get_current_user, nutzer_aus_payload, oauth2_scheme from ..models import User from ..schemas import Token, UserOut -from ..security import create_access_token, verify_password +from ..security import create_access_token, decode_access_token, verify_password +from .settings import get_session_minutes router = APIRouter(prefix="/auth", tags=["auth"]) @@ -21,10 +22,42 @@ def login( status_code=status.HTTP_401_UNAUTHORIZED, detail="Benutzername oder Passwort falsch", ) - token = create_access_token(user_id=user.id, role=user.role.value) + token = create_access_token( + user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db) + ) return Token(access_token=token, role=user.role, username=user.username) +@router.post("/refresh", response_model=Token) +def refresh( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) +) -> Token: + """Stellt der laufenden Sitzung ein frisches Token aus. + + Ohne das laeuft die Frist ab dem *Anmelden*, nicht ab der letzten Nutzung: + Wer die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf + hinaus. Weboberflaeche und App rufen das beim Start auf; damit zaehlt die + eingestellte Dauer ab dem letzten Besuch. + + Bewusst NICHT ueber ``get_current_user``: das nimmt auch langlebige + API-Token an (Home Assistant). Aus so einem Token hier ein JWT zu machen, + haette aus einem jederzeit widerrufbaren Zugang einen unwiderruflichen + gemacht. + """ + payload = decode_access_token(token) + user = nutzer_aus_payload(db, payload) if payload is not None else None + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Ungültige oder abgelaufene Anmeldung", + headers={"WWW-Authenticate": "Bearer"}, + ) + neu = create_access_token( + user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db) + ) + return Token(access_token=neu, role=user.role, username=user.username) + + @router.get("/me", response_model=UserOut) def me(current_user: User = Depends(get_current_user)) -> User: return current_user diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py index 20e2cda..34e782b 100644 --- a/backend/app/routers/oidc.py +++ b/backend/app/routers/oidc.py @@ -35,6 +35,7 @@ from ..schemas import ( Token, ) from ..security import create_access_token +from .settings import get_session_minutes from ..services import oidc as dienst from ..services import oidc_state @@ -63,8 +64,12 @@ def _provider_oder_404(slug: str) -> OidcProvider: return provider -def _token_fuer(user: User) -> Token: - token = create_access_token(user_id=user.id, role=user.role.value) +def _token_fuer(user: User, db: Session) -> Token: + # Die Sitzungsdauer steht in den Einstellungen und gilt fuer JEDEN Weg + # herein - nach Single Sign-on genauso wie nach dem Passwort-Login. + token = create_access_token( + user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db) + ) return Token(access_token=token, role=user.role, username=user.username) @@ -209,7 +214,7 @@ def anmeldung_abschliessen( logger.warning("SSO '%s' gescheitert: %s", slug, fehler) return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None) - token = _token_fuer(nutzer) + token = _token_fuer(nutzer, db) logger.info("SSO '%s': '%s' angemeldet.", slug, nutzer.username) # Token im Fragment, nicht in der Abfragezeichenkette: Fragmente werden nicht # an den Server geschickt und landen damit auch nicht in den nginx-Logs. @@ -253,7 +258,7 @@ def anmeldung_app(slug: str, payload: OidcNativeLogin, db: Session = Depends(get raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(fehler)) from fehler logger.info("SSO '%s' (App): '%s' angemeldet.", slug, nutzer.username) - return _token_fuer(nutzer) + return _token_fuer(nutzer, db) # --------------------------------------------------------------------------- @@ -394,7 +399,7 @@ def offene_anmeldung_neues_konto(code: str, db: Session = Depends(get_db)) -> To except dienst.OidcFehler as fehler: raise HTTPException(status.HTTP_403_FORBIDDEN, str(fehler)) from fehler logger.info("SSO '%s': neues Konto '%s' auf Wunsch angelegt.", provider.slug, nutzer.username) - return _token_fuer(nutzer) + return _token_fuer(nutzer, db) @router.post("/pending/{code}/verknuepfen", response_model=Token) @@ -417,4 +422,4 @@ def offene_anmeldung_verknuepfen( ) raise HTTPException(code_http, str(fehler)) from fehler logger.info("SSO '%s': mit bestehendem Konto '%s' verknuepft.", provider.slug, nutzer.username) - return _token_fuer(nutzer) + return _token_fuer(nutzer, db) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 3c4f53f..32a2f9c 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from ..config import get_settings @@ -14,6 +14,11 @@ EXPIRY_WARNING_KEY = "expiry_warning_days" # Treffer vorgeschlagen wird. RECEIPT_THRESHOLD_KEY = "receipt_match_threshold" RECEIPT_THRESHOLD_DEFAULT = 45 +# Wie lange eine Anmeldung gilt, in Tagen. 0 = unbegrenzt. +SESSION_DAYS_KEY = "session_days" +# Zehn Jahre. Wer laenger will, nimmt "unbegrenzt" – ein Token mit einem +# Ablauf im naechsten Jahrhundert waere dasselbe, nur unehrlicher. +SESSION_DAYS_MAX = 3650 def get_expiry_warning_days(db: Session) -> int: @@ -37,6 +42,31 @@ def get_receipt_match_threshold(db: Session) -> int: return RECEIPT_THRESHOLD_DEFAULT +def get_session_days(db: Session) -> int: + """Gueltigkeitsdauer einer Anmeldung in Tagen; **0 heisst unbegrenzt**. + + Ohne Eintrag gilt der Rueckfallwert aus der Umgebung + (``JWT_EXPIRE_MINUTES``), damit eine frische Installation nicht erst + eingestellt werden muss. + """ + vorgabe = max(1, round(get_settings().jwt_expire_minutes / (60 * 24))) + row = db.get(Setting, SESSION_DAYS_KEY) + if row is None: + return vorgabe + try: + return max(0, min(SESSION_DAYS_MAX, int(row.value))) + except ValueError: + return vorgabe + + +def get_session_minutes(db: Session) -> int: + """Dasselbe in Minuten, wie :func:`create_access_token` es erwartet. + + Die 0 fuer "unbegrenzt" ueberlebt die Umrechnung unveraendert. + """ + return get_session_days(db) * 24 * 60 + + @router.get("", response_model=list[SettingOut]) def list_settings( db: Session = Depends(get_db), _: User = Depends(get_current_user) @@ -45,6 +75,7 @@ def list_settings( known = {r.key: r.value for r in rows} known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db))) known.setdefault(RECEIPT_THRESHOLD_KEY, str(get_receipt_match_threshold(db))) + known.setdefault(SESSION_DAYS_KEY, str(get_session_days(db))) return [SettingOut(key=k, value=v) for k, v in known.items()] @@ -55,6 +86,17 @@ def set_setting( db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> SettingOut: + if key == SESSION_DAYS_KEY: + # Ohne diese Pruefung wuerde get_session_days einen Tippfehler still + # auf die Vorgabe zurueckdrehen – und der Admin glaubte, es sei gesetzt. + try: + tage = int(value) + except ValueError: + raise HTTPException(400, "Sitzungsdauer muss eine Zahl in Tagen sein (0 = unbegrenzt)") + if not 0 <= tage <= SESSION_DAYS_MAX: + raise HTTPException(400, f"Sitzungsdauer: 0 (unbegrenzt) bis {SESSION_DAYS_MAX} Tage") + value = str(tage) + row = db.get(Setting, key) if row is None: row = Setting(key=key, value=value) diff --git a/backend/app/security.py b/backend/app/security.py index 76f67a9..8cb6f61 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -31,7 +31,9 @@ def verify_password(password: str, password_hash: str | None) -> bool: return False -def create_access_token(user_id: int, role: str) -> str: +def create_access_token( + user_id: int, role: str, gueltig_minuten: int | None = None +) -> str: """Stellt ein Sitzungs-Token aus, das den Benutzer ueber seine ID benennt. Frueher stand hier der Benutzername. Damit war der Name die Kennung der @@ -41,9 +43,18 @@ def create_access_token(user_id: int, role: str) -> str: ``uid`` steht zusaetzlich zu ``sub`` drin, damit sich alte Token (``sub`` = Name) eindeutig von neuen unterscheiden lassen – ein Benutzer koennte sonst schlicht "42" heissen. + + ``gueltig_minuten`` kommt aus der Einstellung ``session_days``; ``None`` + nimmt den Rueckfallwert aus der Umgebung. **0 heisst unbegrenzt** – dann + bekommt das Token gar kein ``exp`` und laeuft nie ab. Diese Wahl ist + bewusst folgenreich: Ein ausgestelltes Token laesst sich nicht + zurueckrufen, es gibt keine Sperrliste. Wer alle Anmeldungen beenden will, + muss JWT_SECRET aendern. """ - expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) - payload = {"sub": str(user_id), "uid": user_id, "role": role, "exp": expire} + minuten = settings.jwt_expire_minutes if gueltig_minuten is None else gueltig_minuten + payload: dict = {"sub": str(user_id), "uid": user_id, "role": role} + if minuten > 0: + payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=minuten) return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) diff --git a/backend/tests/test_sitzungsdauer.py b/backend/tests/test_sitzungsdauer.py new file mode 100644 index 0000000..def88f9 --- /dev/null +++ b/backend/tests/test_sitzungsdauer.py @@ -0,0 +1,151 @@ +"""Wie lange eine Anmeldung gilt – einstellbar, bis hin zu unbegrenzt. + +Vorher war die Frist fest verdrahtet und lief ab dem *Anmelden*: Wer die App +taeglich benutzte, flog trotzdem punktgenau nach Ablauf hinaus. Beides ist +hier geregelt – die Dauer ueber eine Einstellung, das Weiterzaehlen ueber +/auth/refresh. +""" + +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi import HTTPException +from jose import jwt + +from app.config import get_settings +from app.models import ApiToken, Role, Setting, User +from app.routers.auth import refresh +from app.routers.settings import ( + SESSION_DAYS_KEY, + SESSION_DAYS_MAX, + get_session_days, + get_session_minutes, + set_setting, +) +from app.security import create_access_token, hash_api_token, hash_password + + +@pytest.fixture +def eva(db) -> User: + nutzer = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin) + db.add(nutzer) + db.commit() + db.refresh(nutzer) + return nutzer + + +def _inhalt(token: str) -> dict: + e = get_settings() + # Ohne Ablaufpruefung, damit auch ein abgelaufenes Token lesbar bleibt. + return jwt.decode( + token, e.jwt_secret, algorithms=[e.jwt_algorithm], + options={"verify_exp": False}, + ) + + +# ---------------------------------------------------------------- Einstellung + +def test_ohne_eintrag_gilt_der_wert_aus_der_umgebung(db): + erwartet = round(get_settings().jwt_expire_minutes / (60 * 24)) + assert get_session_days(db) == erwartet + + +def test_gesetzte_dauer_landet_im_token(db, eva): + set_setting(key=SESSION_DAYS_KEY, value="60", db=db, _=eva) + + assert get_session_days(db) == 60 + assert get_session_minutes(db) == 60 * 24 * 60 + + inhalt = _inhalt( + create_access_token(eva.id, eva.role.value, gueltig_minuten=get_session_minutes(db)) + ) + tage = (datetime.fromtimestamp(inhalt["exp"], timezone.utc) + - datetime.now(timezone.utc)).days + # 59, weil der angebrochene Tag abgeschnitten wird. + assert tage == 59 + + +def test_null_heisst_unbegrenzt(db, eva): + """Die Zusage "einfach fuer immer" – ein Token ganz ohne Ablauf.""" + set_setting(key=SESSION_DAYS_KEY, value="0", db=db, _=eva) + + assert get_session_minutes(db) == 0 + inhalt = _inhalt(create_access_token(eva.id, eva.role.value, gueltig_minuten=0)) + assert "exp" not in inhalt + + +def test_unbegrenztes_token_wird_angenommen(db, eva): + """Ein Token ohne exp darf nicht an der Pruefung scheitern.""" + from app.deps import get_current_user + + token = create_access_token(eva.id, eva.role.value, gueltig_minuten=0) + assert get_current_user(token=token, db=db).id == eva.id + + +@pytest.mark.parametrize("wert", ["viel", "-1", str(SESSION_DAYS_MAX + 1), "3.5"]) +def test_unsinnige_dauer_wird_abgelehnt(db, eva, wert): + """Absagen statt still auf die Vorgabe zurueckdrehen – sonst glaubt der + Admin, es sei gesetzt.""" + with pytest.raises(HTTPException) as fehler: + set_setting(key=SESSION_DAYS_KEY, value=wert, db=db, _=eva) + assert fehler.value.status_code == 400 + assert db.get(Setting, SESSION_DAYS_KEY) is None + + +def test_beschaedigter_eintrag_faellt_auf_die_vorgabe_zurueck(db): + """Von Hand in der Datenbank verbogen: lieber die Vorgabe als ein Absturz.""" + db.add(Setting(key=SESSION_DAYS_KEY, value="kaputt")) + db.commit() + assert get_session_days(db) == round(get_settings().jwt_expire_minutes / (60 * 24)) + + +def test_dauer_steht_in_der_einstellungsliste(db, eva): + from app.routers.settings import list_settings + + schluessel = {s.key for s in list_settings(db=db, _=eva)} + assert SESSION_DAYS_KEY in schluessel + + +# ---------------------------------------------------------------- Verlaengern + +def test_refresh_gibt_ein_frisches_token(db, eva): + set_setting(key=SESSION_DAYS_KEY, value="30", db=db, _=eva) + alt = create_access_token(eva.id, eva.role.value, gueltig_minuten=60) + + neu = refresh(token=alt, db=db) + + assert neu.username == "eva" + assert _inhalt(neu.access_token)["exp"] > _inhalt(alt)["exp"] + + +def test_refresh_uebernimmt_die_neue_dauer(db, eva): + """Der Sinn der Sache: eine Aenderung greift, ohne dass man sich abmeldet.""" + alt = create_access_token(eva.id, eva.role.value, gueltig_minuten=60) + set_setting(key=SESSION_DAYS_KEY, value="0", db=db, _=eva) + + assert "exp" not in _inhalt(refresh(token=alt, db=db).access_token) + + +def test_refresh_lehnt_ein_abgelaufenes_token_ab(db, eva): + # Von Hand gebaut: ueber create_access_token laesst sich kein abgelaufenes + # Token bestellen – alles <= 0 heisst dort "unbegrenzt". + e = get_settings() + abgelaufen = jwt.encode( + {"sub": str(eva.id), "uid": eva.id, "role": eva.role.value, + "exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, + e.jwt_secret, algorithm=e.jwt_algorithm, + ) + with pytest.raises(HTTPException) as fehler: + refresh(token=abgelaufen, db=db) + assert fehler.value.status_code == 401 + + +def test_refresh_macht_aus_einem_api_token_kein_jwt(db, eva): + """Ein API-Token (Home Assistant) ist jederzeit widerrufbar. Ein daraus + gemachtes JWT waere es nicht – deshalb hier eine Absage.""" + db.add(ApiToken(name="ha", token_hash=hash_api_token("geheim"), user_id=eva.id)) + db.commit() + + with pytest.raises(HTTPException) as fehler: + refresh(token="geheim", db=db) + assert fehler.value.status_code == 401 diff --git a/deploy/.env.example b/deploy/.env.example index 40e7161..97421c0 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -10,8 +10,14 @@ POSTGRES_PASSWORD=vorrania POSTGRES_DB=vorrania # Sicherheit – im Betrieb unbedingt ändern! +# Ein neuer Wert beendet ALLE laufenden Anmeldungen – das ist zugleich der +# einzige Weg, eine unbegrenzt gültige Sitzung wieder loszuwerden. JWT_SECRET=change-me-please +# Wie lange eine Anmeldung gilt, in Minuten. Nur der Startwert: im Betrieb +# stellt man das unter Einstellungen -> Anmeldung ein (auch "unbegrenzt"). +#JWT_EXPIRE_MINUTES=43200 + # Erster Admin-Benutzer (wird nur beim allerersten Start angelegt) ADMIN_USERNAME=admin ADMIN_PASSWORD=changeme diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 654213e..c342ea5 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -131,6 +131,21 @@ actor APIClient { try await send(try makeRequest("/auth/me"), as: MeResponse.self) } + /// Schiebt die Frist der laufenden Sitzung nach vorn. + /// + /// Ohne das zaehlt die am Server eingestellte Dauer ab dem *Anmelden*: Wer + /// die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf hinaus. + /// + /// Bewusst ohne die Erneuerungsschicht: Ein 401 heisst hier, dass die + /// Sitzung wirklich vorbei ist – dann gehoert sie beendet und nicht ueber + /// einen Umweg gerettet. + func sitzungVerlaengern() async throws -> TokenResponse { + let request = try makeRequest("/auth/refresh", method: "POST") + let (daten, antwort) = try await URLSession.shared.data(for: request) + try check(antwort, data: daten) + return try JSONDecoder().decode(TokenResponse.self, from: daten) + } + /// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login. func oidcProviders() async throws -> [OidcProviderResponse] { let (daten, antwort) = try await URLSession.shared.data( diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index 31c74c3..2d06962 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -23,6 +23,9 @@ struct RootView: View { if session.isLoggedIn { await display.load() await session.refreshMe() + // Nach refreshMe, damit eine wirklich abgelaufene Sitzung erst + // sauber endet, statt hier noch verlaengert zu werden. + await session.sitzungVerlaengern() } } // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine diff --git a/ios/Sources/Session.swift b/ios/Sources/Session.swift index 14fac10..c7da345 100644 --- a/ios/Sources/Session.swift +++ b/ios/Sources/Session.swift @@ -224,6 +224,24 @@ final class Session: ObservableObject { // kurzer Netzausfall einen nicht aus der App wirft. } } + + /// Holt beim Start ein frisches Token, damit die am Server eingestellte + /// Sitzungsdauer ab dem letzten Besuch zaehlt und nicht ab dem Anmelden. + /// + /// Schluckt jeden Fehler: Ein aelterer Server kennt /auth/refresh noch gar + /// nicht (404), und eine wirklich abgelaufene Sitzung hat refreshMe() einen + /// Schritt vorher schon beendet. Hier deshalb nie abmelden. + @MainActor + func sitzungVerlaengern() async { + guard isLoggedIn, let profil = activeProfile else { return } + guard let neu = try? await APIClient.shared.sitzungVerlaengern() else { return } + // oidcSlug MUSS mitgegeben werden: store() setzt das Feld immer, und + // ohne ihn verloere das Profil seinen Anmeldedienst – die stille + // Erneuerung nach einem 401 fiele damit aus. + store(token: neu.accessToken, username: neu.username, + isAdmin: neu.role == "admin", persist: stayLoggedIn, + oidcSlug: profil.oidcSlug) + } } /// Minimaler Keychain-Zugriff für ein einzelnes Token. diff --git a/web/src/api.js b/web/src/api.js index dbe7e96..0131f95 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -154,6 +154,10 @@ export const api = { login: (username, password) => request("/auth/login", { method: "POST", form: { username, password } }), me: () => request("/auth/me"), + // Schiebt die Frist der laufenden Sitzung nach vorn. Wird beim Start + // gerufen, damit die eingestellte Dauer ab dem letzten Besuch zaehlt und + // nicht ab dem Anmelden. + sitzungVerlaengern: () => request("/auth/refresh", { method: "POST" }), // Welche Single-Sign-on-Anbieter eingerichtet sind. Kommt vom Server, weil die // Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer // und es taucht kein SSO-Knopf auf. diff --git a/web/src/auth.jsx b/web/src/auth.jsx index a77cca9..8eeeeaa 100644 --- a/web/src/auth.jsx +++ b/web/src/auth.jsx @@ -13,6 +13,22 @@ export function AuthProvider({ children }) { // meldet einen ab. const [startFehler, setStartFehler] = useState(null); + // Holt ein frisches Token, damit die eingestellte Dauer ab dem letzten + // Besuch zaehlt statt ab dem Anmelden. + // + // Der Fehler wird geschluckt, weil ein Server- oder Netzfehler hier nichts + // kostet: das bisherige Token gilt ja weiter. Ein 401 waere etwas anderes – + // um den kuemmert sich bereits die zentrale Abmeldung in api.js, und er kann + // direkt nach einem geglueckten me() ohnehin nicht auftreten. + async function sitzungVerlaengern() { + try { + const res = await api.sitzungVerlaengern(); + setToken(res.access_token); + } catch { + /* siehe oben */ + } + } + async function pruefen() { setStartFehler(null); if (!getToken()) { @@ -21,6 +37,7 @@ export function AuthProvider({ children }) { } try { setUser(await api.me()); + await sitzungVerlaengern(); } catch (err) { // NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines // Server-Neustarts ist kein Grund, die Sitzung wegzuwerfen. diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index 4c3e5a3..15a6726 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -11,6 +11,18 @@ import { applyFavicon } from "../branding"; const EXPIRY_KEY = "expiry_warning_days"; const RECEIPT_KEY = "receipt_match_threshold"; +const SESSION_KEY = "session_days"; + +// Feste Stufen statt eines Zahlenfeldes: "wie lange bleibe ich angemeldet" ist +// eine Entscheidung, keine Rechenaufgabe. 0 heisst unbegrenzt. +const SITZUNGSDAUERN = [ + { value: "7", label: "7 Tage" }, + { value: "30", label: "30 Tage" }, + { value: "90", label: "3 Monate" }, + { value: "180", label: "6 Monate" }, + { value: "365", label: "1 Jahr" }, + { value: "0", label: "Unbegrenzt – nie abmelden" }, +]; export default function Settings() { const { reload } = useSettings(); @@ -18,6 +30,7 @@ export default function Settings() { const [days, setDays] = useState(""); const [threshold, setThreshold] = useState(""); const [dateFormat, setDateFormat] = useState("de"); + const [sessionDays, setSessionDays] = useState("30"); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -27,6 +40,7 @@ export default function Settings() { setDays(settings.find((s) => s.key === EXPIRY_KEY)?.value ?? "7"); setThreshold(settings.find((s) => s.key === RECEIPT_KEY)?.value ?? "45"); setDateFormat(settings.find((s) => s.key === DATE_FORMAT_KEY)?.value ?? "de"); + setSessionDays(settings.find((s) => s.key === SESSION_KEY)?.value ?? "30"); } catch (err) { setError(err.message); } @@ -41,6 +55,7 @@ export default function Settings() { await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10))); await api.setSetting(RECEIPT_KEY, String(Math.max(0, Math.min(100, parseInt(threshold, 10) || 0)))); await api.setSetting(DATE_FORMAT_KEY, dateFormat); + await api.setSetting(SESSION_KEY, sessionDays); await reload(); toast("Gespeichert."); } catch (err) { @@ -50,6 +65,13 @@ export default function Settings() { } } + // Ein ueber die API gesetzter Zwischenwert (z.B. 60) steht nicht in der Liste. + // Ohne diesen Zusatz zeigte die Auswahl nichts an und das Speichern haette den + // Wert klammheimlich veraendert. + const dauerAuswahl = SITZUNGSDAUERN.some((d) => d.value === sessionDays) + ? SITZUNGSDAUERN + : [...SITZUNGSDAUERN, { value: sessionDays, label: `${sessionDays} Tage` }]; + // Beispiel mit einem festen Datum, damit man die Wirkung sofort sieht. const preview = formatDate("2026-12-23", dateFormat); @@ -89,6 +111,31 @@ export default function Settings() { sicherere Vorschläge), niedriger = mehr Vorschläge.
++ Gilt für die Website und die App gleichermaßen. Die Frist zählt ab dem + letzten Besuch – wer regelmäßig vorbeischaut, wird also gar nicht + abgemeldet. Geändert wird sie beim nächsten Seitenaufruf übernommen. +
+ {sessionDays === "0" && ( +
+