diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py
index 103aa8f..7fc1700 100644
--- a/services/presentation/app/features.py
+++ b/services/presentation/app/features.py
@@ -109,6 +109,9 @@ ROUTES: dict[tuple[str, str], str | None] = {
("GET", "/reverse"): None,
("GET", "/timezone"): None,
("GET", "/health"): None,
+ # Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego,
+ # co mu przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
+ ("GET", "/wyloguj"): None,
}
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 1ec7e53..4ef4c07 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -86,6 +86,9 @@ def _perms(request: Request) -> frozenset[str]:
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
# nikt już nie pamięta.
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
+# Kto jest zalogowany i czy logowanie w ogóle działa — pod przycisk wyjścia.
+templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
+templates.env.globals["auth_on"] = security.auth_enabled
templates.env.globals["can"] = lambda request, key: key in _perms(request)
@@ -879,6 +882,48 @@ def accounts_delete(request: Request, login: str = Form(...)):
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
+# ---------------- Wylogowanie (LOG-32) ----------------
+# HTTP Basic NIE MA prawdziwego wylogowania i trzeba to powiedzieć wprost:
+# przeglądarka zapamiętuje dane logowania i dosyła je sama przy każdym żądaniu,
+# więc „zapomnienie" po stronie serwera nic nie daje — kolejne kliknięcie weszłoby
+# z powrotem. Nie ma czego wyczyścić w sesji, bo sesji nie ma.
+#
+# Działa natomiast jedno: doprowadzić do tego, żeby PRZEGLĄDARKA porzuciła to, co
+# zapamiętała. Robimy to dwutorowo:
+#
+# 1. ta trasa odpowiada ZAWSZE 401 z nagłówkiem WWW-Authenticate, co zmusza
+# przeglądarkę do ponownego zapytania o hasło — działa też bez JavaScriptu,
+# 2. wyloguj.js dokłada to, co faktycznie CZYŚCI pamięć przeglądarki: żądanie
+# z celowo błędnymi danymi, którym nadpisuje ona zapamiętany wpis.
+#
+# Sam punkt 1 zostawiłby stare dane w pamięci przeglądarki: po anulowaniu okienka
+# wystarczyłoby wejść na dowolny adres, żeby wrócić do środka. Dlatego oba naraz.
+
+@app.get("/wyloguj", response_class=HTMLResponse)
+def wyloguj() -> HTMLResponse:
+ """Kończy sesję przeglądarki. Zawsze 401 — to jest tu WYNIK, nie awaria."""
+ strona = (
+ '
Jeśli komputer jest współdzielony, zamknij okno '
+ 'przeglądarki — to jedyny sposób pewny w każdej przeglądarce.
'
+ ''
+ )
+ return HTMLResponse(
+ strona, status_code=401,
+ headers={"WWW-Authenticate": 'Basic realm="astrololo"',
+ # Bez tego przeglądarka mogłaby oddać tę stronę z pamięci
+ # podręcznej przy kolejnym wylogowaniu, nie pytając serwera —
+ # a wtedy okienko logowania w ogóle by się nie pojawiło.
+ "Cache-Control": "no-store, no-cache, must-revalidate",
+ "Pragma": "no-cache"},
+ )
+
+
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py
index 3f58b80..4d9e166 100644
--- a/services/presentation/app/security.py
+++ b/services/presentation/app/security.py
@@ -118,7 +118,11 @@ def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
-PUBLIC_PATHS = frozenset({"/health"})
+# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
+# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego
+# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym
+# żądaniu, więc handler i tak widziałby zalogowaną osobę.
+PUBLIC_PATHS = frozenset({"/health", "/wyloguj"})
PUBLIC_PREFIXES = ("/static/",)
_hits: dict[str, deque[float]] = {}
diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css
index 8c10737..ce121dc 100644
--- a/services/presentation/app/static/styles.css
+++ b/services/presentation/app/static/styles.css
@@ -262,3 +262,14 @@ tr.row-archived td { opacity: .55; }
/* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
tr.row-held td { background: rgba(184, 134, 11, .07); }
+
+
+/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */
+/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce,
+ a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy
+ marginesem, nie rozpychaczem — `nav` ma szerokość treści, więc nie ma czego
+ rozpychać. */
+.whoami { color: var(--muted); font-size: .85rem; align-self: center;
+ margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
+nav a.wyloguj { color: var(--muted); }
+nav a.wyloguj:hover { color: var(--fg, inherit); }
diff --git a/services/presentation/app/static/wyloguj.js b/services/presentation/app/static/wyloguj.js
new file mode 100644
index 0000000..6c560d1
--- /dev/null
+++ b/services/presentation/app/static/wyloguj.js
@@ -0,0 +1,44 @@
+// Wylogowanie z HTTP Basic (LOG-32).
+//
+// PROBLEM, KTÓREGO NIE DA SIĘ ROZWIĄZAĆ PO STRONIE SERWERA. Basic nie ma sesji:
+// przeglądarka zapamiętuje login i hasło, po czym dosyła je SAMA przy każdym
+// żądaniu. Serwer nie ma czego „zapomnieć" — następne kliknięcie przyszłoby
+// z kompletem poświadczeń i weszłoby z powrotem.
+//
+// Działa natomiast doprowadzenie do tego, żeby PRZEGLĄDARKA porzuciła to, co
+// zapamiętała: żądanie z celowo błędnymi danymi, którym nadpisuje ona swój wpis
+// dla tej witryny. To jest zachowanie powszechne w przeglądarkach, ale NIE
+// ZAPISANE w żadnym standardzie — dlatego jest tu jako uzupełnienie, a nie
+// jedyny mechanizm: sama trasa /wyloguj odpowiada 401 i wymusza okienko
+// logowania nawet przy wyłączonym JavaScripcie.
+(function () {
+ 'use strict';
+
+ function ready(fn) {
+ if (document.readyState !== 'loading') fn();
+ else document.addEventListener('DOMContentLoaded', fn);
+ }
+
+ ready(function () {
+ var link = document.querySelector('a[data-wyloguj]');
+ if (!link) return;
+
+ link.addEventListener('click', function (e) {
+ e.preventDefault();
+ // Znacznik czasu w loginie: gdyby przeglądarka zapamiętała nawet TE dane,
+ // przy kolejnym wylogowaniu byłyby już inne, więc nie da się przypadkiem
+ // trafić w cokolwiek działającego.
+ var bezsensowne = 'Basic ' + btoa('wyloguj:' + Date.now());
+ fetch(link.href, {
+ headers: {Authorization: bezsensowne},
+ cache: 'no-store',
+ credentials: 'omit'
+ }).catch(function () {
+ // Brak sieci nie może zablokować wyjścia — i tak przechodzimy dalej,
+ // bo sama trasa /wyloguj odpowie 401 i wywoła okienko logowania.
+ }).then(function () {
+ window.location.href = link.href;
+ });
+ });
+ });
+})();
diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html
index 8870ae2..8e9ff00 100644
--- a/services/presentation/app/templates/base.html
+++ b/services/presentation/app/templates/base.html
@@ -13,6 +13,9 @@
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
+ {# Wylogowanie z Basic wymaga kroku po stronie przeglądarki — patrz komentarz
+ w wyloguj.js. Bez JavaScriptu link nadal działa, tylko słabiej. #}
+
@@ -29,6 +32,15 @@
{% if can(request, 'admin') %}
Konta
{% endif %}
+ {# Wyjście na końcu paska i odsunięte: to nie jest zakładka, tylko
+ czynność — pomylenie go z zakładką kosztuje przypadkowe wylogowania.
+ Pokazujemy też KTO jest zalogowany, bo bez tego przycisk wyjścia jest
+ w połowie bezużyteczny: na wspólnym komputerze nie wiadomo, kogo się
+ wylogowuje. Bez włączonego logowania nie ma z czego wychodzić. #}
+ {% if auth_on() %}
+ {{ current_user(request) }}
+ Wyloguj
+ {% endif %}
{% block content %}{% endblock %}
diff --git a/services/presentation/tests/test_kontrola_dostepu.py b/services/presentation/tests/test_kontrola_dostepu.py
index c5c1efe..5d2514f 100644
--- a/services/presentation/tests/test_kontrola_dostepu.py
+++ b/services/presentation/tests/test_kontrola_dostepu.py
@@ -409,3 +409,85 @@ def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch
follow_redirects=False, data={"login": "ala", "password": "x"})
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
"uszkodzony plik został nadpisany — konta by zniknęły"
+
+
+# ── wylogowanie (LOG-32) ─────────────────────────────────────────────────
+# HTTP Basic nie ma sesji: przeglądarka dosyła zapamiętane dane przy KAŻDYM
+# żądaniu, więc serwer nie ma czego zapomnieć. Jedyne, co działa, to doprowadzić
+# do porzucenia tych danych przez przeglądarkę — stąd 401 z tej trasy jest
+# WYNIKIEM, nie awarią.
+
+def test_logout_always_answers_401_even_with_valid_credentials(env, monkeypatch):
+ """Sedno: 200 dla zalogowanej osoby oznaczałoby, że nie da się wyjść."""
+ c = _client(monkeypatch)
+ r = c.get("/wyloguj", headers=_auth("szef", "tajne-szefa"))
+ assert r.status_code == 401
+ assert r.headers.get("WWW-Authenticate", "").lower().startswith("basic"), \
+ "bez tego nagłówka przeglądarka nie zapyta ponownie o hasło"
+
+
+def test_logout_works_without_being_logged_in(env, monkeypatch):
+ """Trasa stoi poza bramką, więc wyjście działa też z nieważnymi danymi —
+ inaczej wylogowanie wymagałoby bycia zalogowanym, co jest błędnym kołem."""
+ c = _client(monkeypatch)
+ assert c.get("/wyloguj").status_code == 401
+
+
+def test_logout_page_is_never_cached(env, monkeypatch):
+ """Oddana z pamięci podręcznej nie dotarłaby do serwera, więc okienko
+ logowania w ogóle by się nie pojawiło."""
+ c = _client(monkeypatch)
+ cc = c.get("/wyloguj").headers.get("Cache-Control", "")
+ assert "no-store" in cc and "no-cache" in cc
+
+
+def test_logout_page_explains_what_actually_ends_the_session(env, monkeypatch):
+ """Nie wolno obiecywać więcej, niż Basic potrafi: pewne wyjście na wspólnym
+ komputerze to zamknięcie przeglądarki i strona ma to mówić wprost."""
+ html = _client(monkeypatch).get("/wyloguj").text
+ assert "Wylogowano" in html
+ assert "zamknij okno" in html.lower()
+ assert 'href="/"' in html, "brak drogi powrotnej"
+
+
+def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch):
+ """Konto bez ani jednego ekranu też ma prawo wyjść."""
+ store.create("bez", "x", [])
+ c = _client(monkeypatch)
+ assert c.get("/wyloguj", headers=_auth("bez", "x")).status_code == 401
+
+
+def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monkeypatch):
+ """Bez pokazania, KTO jest zalogowany, przycisk wyjścia jest w połowie
+ bezużyteczny — na wspólnym komputerze nie wiadomo, kogo się wylogowuje."""
+ store.create("ala", "x", ["chart", "significators"])
+ c = _client(monkeypatch)
+ for kto, haslo in (("szef", "tajne-szefa"), ("ala", "x")):
+ html = c.get("/", headers=_auth(kto, haslo)).text
+ assert 'href="/wyloguj"' in html, f"{kto}: brak wyjścia"
+ assert kto in html, f"{kto}: nie widać, kto jest zalogowany"
+
+
+def test_no_logout_button_when_authentication_is_off(env, monkeypatch):
+ """Bez logowania nie ma z czego wychodzić, a przycisk sugerowałby ochronę,
+ której nie ma."""
+ monkeypatch.delenv("APP_PASSWORD", raising=False)
+ monkeypatch.delenv("APP_USERS", raising=False)
+ c = _client(monkeypatch)
+ assert 'href="/wyloguj"' not in c.get("/").text
+
+
+def test_the_browser_side_step_is_wired_in(env, monkeypatch):
+ """Sam 401 zostawiłby stare dane w pamięci przeglądarki: po anulowaniu
+ okienka wystarczyłoby wejść na dowolny adres, żeby wrócić do środka.
+ Skrypt dokłada żądanie z celowo błędnymi danymi, które ten wpis nadpisuje."""
+ import pathlib
+
+ html = _client(monkeypatch).get("/", headers=_auth("szef", "tajne-szefa")).text
+ assert "wyloguj.js" in html
+ assert "data-wyloguj" in html
+
+ js = (pathlib.Path(__file__).resolve().parents[1] / "app" / "static"
+ / "wyloguj.js").read_text(encoding="utf-8")
+ assert "Authorization" in js and "btoa" in js
+ assert "Date.now()" in js, "bez zmiennej części dane mogłyby się powtórzyć"