fix(auth): восстанавливать раздел и фильтры после входа Portal

This commit is contained in:
Denozordec committed 2026-10-13 16:09:27 +07:00
1 parent 199910eceb
commit 46de87e213
15 files changed
+275 -17

No files matched your search

+12 -3
View File
@@ -5,15 +5,18 @@ import { useRouter } from "next/navigation"
import { import {
clearPortalHandoffFlag, clearPortalHandoffFlag,
clearToken, clearToken,
can,
ensureAuthConfig, ensureAuthConfig,
firstAllowedPath, firstAllowedPath,
getClaims, getClaims,
getToken, getToken,
parseHashToken, parseHashToken,
permissionForPath,
redirectToPortalLogin, redirectToPortalLogin,
redirectToPortalLoginInteractive, redirectToPortalLoginInteractive,
setToken, setToken,
} from "@/lib/auth" } from "@/lib/auth"
import { permittedAuthReturnPath } from "@/lib/auth-return-path"
export default function AuthCallbackPage() { export default function AuthCallbackPage() {
const router = useRouter() const router = useRouter()
@@ -28,6 +31,10 @@ export default function AuthCallbackPage() {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const error = params.get("error") const error = params.get("error")
const returnPath = () => permittedAuthReturnPath(params.get("next"), pathname => {
const permission = permissionForPath(pathname)
return !permission || can(permission)
}, firstAllowedPath())
if (error === "sso_loop" || error === "jwt_rejected") { if (error === "sso_loop" || error === "jwt_rejected") {
redirectToPortalLoginInteractive() redirectToPortalLoginInteractive()
return return
@@ -62,7 +69,7 @@ export default function AuthCallbackPage() {
/* ignore network — proceed */ /* ignore network — proceed */
} }
const next = firstAllowedPath() const next = returnPath()
if (next === "/access-denied") { if (next === "/access-denied") {
router.replace("/access-denied") router.replace("/access-denied")
return return
@@ -77,11 +84,13 @@ export default function AuthCallbackPage() {
router.replace("/access-denied") router.replace("/access-denied")
return return
} }
router.replace(firstAllowedPath()) router.replace(returnPath())
return return
} }
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`) const callback = new URL(`${window.location.origin}/auth/callback`)
if (params.get("next")) callback.searchParams.set("next", params.get("next")!)
const ok = redirectToPortalLogin(callback.toString())
if (!ok) redirectToPortalLoginInteractive() if (!ok) redirectToPortalLoginInteractive()
})() })()
+20 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5 # UX/UI: протокол внешней приёмки 12.5
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md). IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
@@ -696,7 +696,7 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09) ## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -2414,3 +2414,21 @@ Live UI1440/320 с настоящим expired JWT: MM очистил mmapp_token
Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json). Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json).
Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта. Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта.
## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13)
Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь.
Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC.
Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет.
Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены.
Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json).
| Референс | Схема |
| --- | --- |
| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры |
Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта.
+3 -3
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам # План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-13. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-13. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-13 ## Актуальная точка продолжения — 2026-10-13
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты. Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты.
@@ -371,6 +371,6 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09) ## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
+19 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации # UX/UI: журнал реализации
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +38,24 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–196 и ограничения ниже. Runtime backend/frontend ux-peak196. | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–196 и ограничения ниже. Runtime backend/frontend ux-peak196. |
## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13)
Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь.
Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC.
Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет.
Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены.
Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json).
| Референс | Схема |
| --- | --- |
| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры |
Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта.
## 12.5.198 — срок действия Portal JWT, права и выход (2026-10-13) ## 12.5.198 — срок действия Portal JWT, права и выход (2026-10-13)
Использован настоящий JWT, выданный Portal в195 и естественно истёкший к проверке: MM GET servers401, Portal me401 и reissue401. Токен не редактировался и TTL не менялся. На собственном временном reader через документированный PUT /api/v1/admin/users/:id/access удалён доступmm: прежний JWT по-прежнему200 доexp, reissue200 выдаёт текущие claims и новый MM JWT403. Возвращённый доступ и новый reissue восстановили200. Первый неверно адресованный PATCH в диагностическом драйвере был исправлен на PUT/access; изменение source продукта не требовалось. User fixture удалён204. Использован настоящий JWT, выданный Portal в195 и естественно истёкший к проверке: MM GET servers401, Portal me401 и reissue401. Токен не редактировался и TTL не менялся. На собственном временном reader через документированный PUT /api/v1/admin/users/:id/access удалён доступmm: прежний JWT по-прежнему200 доexp, reissue200 выдаёт текущие claims и новый MM JWT403. Возвращённый доступ и новый reissue восстановили200. Первый неверно адресованный PATCH в диагностическом драйвере был исправлен на PUT/access; изменение source продукта не требовалось. User fixture удалён204.
+21 -3
View File
@@ -1,8 +1,8 @@
# Оставшаяся внешняя приёмка — 2026-10-13 # Оставшаяся внешняя приёмка — 2026-10-13
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
Это актуальная матрица после 12.5.198. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. Это актуальная матрица после 12.5.199. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend/frontend `ux-peak196`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend/frontend `ux-peak196`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
@@ -26,7 +26,7 @@
## Следующая последовательность — 2026-10-13 ## Следующая последовательность — 2026-10-13
1. **199 — SSO context:** сохранить исходный разрешённый раздел/query после login, запретить внешние/auth callback redirect. Затем scoped deploy и два return_to, ограниченные429 в198. 1. **200 — crash/lock-loss collector:** собственная ограниченная БД и процессы, без остановки production PG или native CHR. Telegram197 whole-live failure остаётся отдельной диагностической границей.
2. **Профильный контроль:** для новых изменений выполнять затронутые SQL/API/UI проверки. Общая регрессия192 выполнена; не повторять её без изменений общей основы или нового основания. 2. **Профильный контроль:** для новых изменений выполнять затронутые SQL/API/UI проверки. Общая регрессия192 выполнена; не повторять её без изменений общей основы или нового основания.
3. **Внешние зависимости:** Portal issuer/login/revoke, Telegram bot/chat/разрешённое сообщение, клиентские ОС, независимые WAN/public IPv6 и scale/multi-instance — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций. 3. **Внешние зависимости:** Portal issuer/login/revoke, Telegram bot/chat/разрешённое сообщение, клиентские ОС, независимые WAN/public IPv6 и scale/multi-instance — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций.
@@ -1330,3 +1330,21 @@ Live UI1440/320 с настоящим expired JWT: MM очистил mmapp_token
Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json). Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json).
Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта. Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта.
## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13)
Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь.
Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC.
Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет.
Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены.
Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json).
| Референс | Схема |
| --- | --- |
| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры |
Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта.
+19 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI # Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -3719,3 +3719,21 @@ Live UI1440/320 с настоящим expired JWT: MM очистил mmapp_token
Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json). Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json).
Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта. Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта.
## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13)
Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь.
Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC.
Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет.
Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены.
Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json).
| Референс | Схема |
| --- | --- |
| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры |
Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта.
+20 -2
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-10 # Тестовый сервер UX/UI — 2026-10-10
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
| Параметр | Проверенное состояние | | Параметр | Проверенное состояние |
| --- | --- | | --- | --- |
@@ -708,7 +708,7 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09) ## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -2145,3 +2145,21 @@ Live UI1440/320 с настоящим expired JWT: MM очистил mmapp_token
Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json). Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json).
Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта. Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта.
## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13)
Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь.
Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC.
Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет.
Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены.
Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json).
| Референс | Схема |
| --- | --- |
| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры |
Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта.
@@ -0,0 +1,14 @@
{
"date": "2026-10-13",
"block": "12.5.199",
"sourceFilesMatched": 527,
"sourceHashAlgorithm": "sha256 raw bytes before Docker build",
"productionBuildPassed": true,
"frontendImage": "mmapp-test/frontend:ux-portal199",
"frontendImageId": "sha256:d3041db1ab0443211bc65bbe819438b9c9647763421fb7fe0dddae786c943cbe4",
"backendImage": "mmapp-test/backend:ux-peak196",
"rollbackFrontendTag": "ux-peak196",
"portalVersion": "1.3.1",
"authRequired": true,
"scope": "Only frontend image tag changed; actual expired session SSO UI tested separately"
}
@@ -0,0 +1,14 @@
{
"date": "2026-10-13",
"block": "12.5.199",
"stats": {
"startTime": "2026-10-13T09:01:20.154Z",
"duration": 24970.957000000002,
"expected": 12,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"errors": [],
"scope": "10 callback/query/permissions/unsafe target/handoff fixtures + 2 runtime link fixtures; not real Portal"
}
@@ -0,0 +1,36 @@
{
"date": "2026-10-13",
"block": "12.5.199",
"cases": [
{
"name": "expired token recovery",
"width": 1440,
"expiredMmTokenCleared": true,
"portalLogin": true,
"recoveredActualSso": true,
"landingPath": "/servers",
"originalPathRestored": true,
"noFragment": true,
"noOverflow": true,
"pageErrors": 0,
"restoredSearch": "?server=42&tab=details"
},
{
"name": "expired token recovery",
"width": 320,
"expiredMmTokenCleared": true,
"portalLogin": true,
"recoveredActualSso": true,
"landingPath": "/servers",
"originalPathRestored": true,
"noFragment": true,
"noOverflow": true,
"pageErrors": 0,
"restoredSearch": "?server=42&tab=details"
}
],
"realRuntime": true,
"apiMocks": false,
"blockedUnexpectedNavigationForSafety": true,
"serverWrites": 0
}
@@ -0,0 +1,32 @@
{
"date": "2026-10-13",
"block": "12.5.199",
"cases": [
{
"name": "disallowed return_to",
"target": "http://localhost:3001/auth/callback",
"externalNavigationAttempts": 0,
"portalStayed": true,
"apiMocks": false
},
{
"name": "disallowed return_to",
"target": "http://localhost.evil.invalid:3000/auth/callback",
"externalNavigationAttempts": 0,
"portalStayed": true,
"apiMocks": false
},
{
"name": "Portal browser logout",
"loginVisible": true,
"tokenStorageCleared": true
}
],
"realRuntime": true,
"apiMocks": false,
"blockedUnexpectedNavigationForSafety": true,
"serverWrites": 0,
"remaining": [],
"complete": true,
"limitation": "Previous lookalike target used an invalid port and could not render login; corrected syntactically valid hostname tested separately."
}
+9
View File
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict'
import {safeAuthReturnPath,permittedAuthReturnPath} from './auth-return-path'
for(const input of [null,undefined,'','https://outside.invalid','//outside.invalid','/\\outside.invalid','/auth/callback?next=/servers','/api/servers','/access-denied','/a/../auth/callback','/%2f%2foutside','/%5coutside','/%61uth/callback','/%73ettings','/servers\n','/servers?'+ 'a'.repeat(2048)])assert.equal(safeAuthReturnPath(input),null,String(input))
assert.equal(safeAuthReturnPath('/servers?server=42&tab=details#private-token'),'/servers?server=42&tab=details')
assert.equal(safeAuthReturnPath('/traffic?period=24h&name=%D0%A2%D0%B5%D1%81%D1%82'),'/traffic?period=24h&name=%D0%A2%D0%B5%D1%81%D1%82')
assert.equal(permittedAuthReturnPath('/servers?server=42',p=>p==='/servers','/dashboard'),'/servers?server=42')
assert.equal(permittedAuthReturnPath('/settings',p=>p==='/servers','/servers'),'/servers')
assert.equal(permittedAuthReturnPath('//outside.invalid',()=>true,'/servers'),'/servers')
console.log('auth return path: unsafe targets, normalized auth loops, query preservation and permission fallback passed')
+22
View File
@@ -0,0 +1,22 @@
/** Accept an application path, never an external URL or another auth handoff. */
export function safeAuthReturnPath(value: string | null | undefined): string | null {
if (!value || value.length > 2048 || !value.startsWith("/") || value.startsWith("//")) return null
if (/[\\\u0000-\u0020\u007f]/.test(value)) return null
try {
const url = new URL(value, "https://mmapp.invalid")
if (url.origin !== "https://mmapp.invalid" || /%/.test(url.pathname)) return null
if (/^\/(auth|api|access-denied)(\/|$)/.test(url.pathname)) return null
return url.pathname + url.search
} catch {
return null
}
}
export function permittedAuthReturnPath(
value: string | null | undefined,
allowed: (pathname: string) => boolean,
fallback: string,
): string {
const path = safeAuthReturnPath(value)
return path && allowed(new URL(path, "https://mmapp.invalid").pathname) ? path : fallback
}
+6 -2
View File
@@ -1,4 +1,5 @@
/** Portal JWT storage + claims helpers for MikrotikManager. */ /** Portal JWT storage + claims helpers for MikrotikManager. */
import { safeAuthReturnPath } from "./auth-return-path"
const TOKEN_KEY = "mmapp_token" const TOKEN_KEY = "mmapp_token"
const HANDOFF_KEY = "mmapp_auth_401_handoff" const HANDOFF_KEY = "mmapp_auth_401_handoff"
@@ -125,9 +126,12 @@ export function redirectToPortalLogin(returnTo?: string): boolean {
return false return false
} }
markPortalHandoff() markPortalHandoff()
const callback = returnTo ?? `${window.location.origin}/auth/callback` const callback = new URL(returnTo ?? `${window.location.origin}/auth/callback`)
const next = safeAuthReturnPath(callback.searchParams.get("next")) ??
safeAuthReturnPath(window.location.pathname + window.location.search)
if (next) callback.searchParams.set("next", next)
const url = new URL(authPortalUrl()) const url = new URL(authPortalUrl())
url.searchParams.set("return_to", callback) url.searchParams.set("return_to", callback.toString())
window.location.assign(url.toString()) window.location.assign(url.toString())
return true return true
} }
+28
View File
@@ -0,0 +1,28 @@
import {test,expect} from '@playwright/test'
const token=(permissions:string[])=>[Buffer.from('{}').toString('base64url'),Buffer.from(JSON.stringify({sub:'own199',name:'Own199',email:'[email protected]',apps:['mm'],permissions,exp:Math.floor(Date.now()/1000)+600})).toString('base64url'),'fixture'].join('.')
for(const width of [1440,320]){
for(const [name,next,permissions,expected]of [
['query','/servers?server=42&tab=details',['mm:servers:read'],'/servers?server=42&tab=details'],
['denied','/settings',['mm:servers:read'],'/servers'],
['external','//outside.invalid',['mm:servers:read'],'/servers'],
['loop','/auth/callback?next=/servers',['mm:servers:read'],'/servers'],
] as const)test(`SSO return ${name} ${width}`,async({page})=>{
await page.setViewportSize({width,height:1000})
await page.route('**/api/**',r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==='/api/auth/config'?{required:true,portal_url:'https://portal.example.test'}:path==='/api/servers'?[]:path==='/api/v1/app-switcher'?{menuLabel:'Приложения',apps:[]}:{}})})
await page.goto('/auth/callback?next='+encodeURIComponent(next)+'#access_token='+token([...permissions]))
await expect.poll(()=>new URL(page.url()).pathname+new URL(page.url()).search).toBe(expected)
expect(new URL(page.url()).hash).toBe('')
await expect(page.getByRole('button',{name:'Добавить сервер',exact:true})).toBeVisible()
})
test(`SSO handoff captures query ${width}`,async({page})=>{
await page.setViewportSize({width,height:1000})
await page.route('**/api/auth/config',r=>r.fulfill({json:{required:true,portal_url:'https://portal.example.test/'}}))
await page.route('https://portal.example.test/**',r=>r.fulfill({contentType:'text/html',body:'<p>Own Portal fixture</p>'}))
await page.goto('/servers?server=42&tab=details')
await expect.poll(()=>new URL(page.url()).hostname).toBe('portal.example.test')
const callback=new URL(new URL(page.url()).searchParams.get('return_to')!)
expect(callback.origin).toBe('http://127.0.0.1:3100')
expect(callback.pathname).toBe('/auth/callback')
expect(callback.searchParams.get('next')).toBe('/servers?server=42&tab=details')
})
}