diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index 38f8667..b0de6c1 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -83,9 +83,10 @@ @router.get("/logout") -async def logout(request: Request) -> dict[str, bool]: +async def logout(request: Request) -> RedirectResponse: request.session.clear() - return {"ok": True} + # на экран входа SPA: там единственная кнопка «Войти» (инициирует SSO-флоу) + return RedirectResponse("/login") @router.get("/me") diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index b906854..38d3e4d 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -18,6 +18,14 @@ assert client.get("/auth/me").status_code == 401 +def test_logout_redirects_to_login_screen() -> None: + """Выход не оставляет пользователя на JSON {"ok": true}: редирект на + экран входа SPA (ТЗ 3.17), там единственная кнопка «Войти».""" + resp = client.get("/auth/logout", follow_redirects=False) + assert resp.status_code in (302, 307) + assert resp.headers["location"] == "/login" + + def test_login_redirects_to_sso() -> None: base_url = get_settings().gauth_base_url resp = client.get("/auth/login", follow_redirects=False) diff --git a/docs/TZ.md b/docs/TZ.md index bcd9807..a4f6193 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.37 | +| Версия ТЗ | 0.38 | | Дата | 2026-09-22 | | Статус | На обсуждении | @@ -439,6 +439,18 @@ отозвать — при блокировке/выходе доступ закрывается на стороне SSO; у gntodo webhook отзывает только MCP-токены. +### 3.17. Экран входа (0.38) + +- Отдельный маршрут `/login` вне навигационной оболочки: логотип, название и + единственная кнопка «Войти» — инициация SSO-флоу (`/auth/login` с `return_to`). +- Не залогиненный пользователь направляется на экран входа с любого маршрута + (`/auth/me` → 401); после логина возвращается на исходный адрес. Истёкшая + сессия в API/SSE-коде тоже ведёт на `/login`, а не на бэкендовый SSO-флоу. +- `/auth/logout` после очистки сессии редиректит на `/login` (не оставляет + пользователя на JSON-ответе). +- Оффлайн-исключение (ТЗ 3.15): сетевая ошибка `/auth/me` не считается «не + залогинен» — оффлайн-оболочка открывается без редиректа. + ## 4. Нефункциональные требования diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d5f10b4..c766586 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -48,13 +48,16 @@ } onMounted(async () => { - // оффлайн-старт PWA (ТЗ 3.15): оболочка открывается без сети — /auth/me + // оффлайн-старт PWA (ТЗ 3.15): оболочка открывается без сети — fetch // реджектится, и надо не уронить onMounted молча, а просто остаться гостем let me: { user: UserInfo; account_url?: string } | null = null + let unauthorized = false try { const res = await fetch('/auth/me') if (res.ok) { me = await res.json() + } else if (res.status === 401) { + unauthorized = true } } catch { me = null @@ -68,6 +71,12 @@ // SSE-события (ТЗ 3.14): только для авторизованных — иначе EventSource // будет бесконечно ловить 401 startRealtime() + } else if (unauthorized) { + // не залогинен — экран входа (ТЗ 3.17); после логина вернём на этот же адрес. + // Сетевая ошибка (оффлайн) — не редиректим: оффлайн-оболочка без сети. + void router.replace({ path: '/login', query: { return_to: route.fullPath } }) + // настройки не запрашиваем: лишний 401 на /login ни к чему + return } // валюта и язык — глобальные настройки; язык без переопределения берётся из SSO try { @@ -82,7 +91,9 @@