Веб-консоль¶
Веб-консоль (platform-web) — браузерный интерфейс панели платформы на
Next.js: вход, администрирование tenant'а, профиль, уведомления и модули
продуктов — консоль Control Plane и модули вертикальных пакетов. Статья
описывает сборку и параметры, устройство сессии (cookie, silent refresh),
серверные прокси к шлюзу и управление видимостью модулей. Работа оператора
в самой консоли описана в Консоль платформы.
Замороженный периметр
platform-web входит в профиль compose platform (заморожен,
TAI-ADR-0034, TAI-ADR-0039). Исходный код — отдельный компонент
platform-web.
Архитектура¶
flowchart LR
B[Браузер] -->|cookie сессии| W["platform-web (Next.js, :3000)"]
W -->|"middleware: silent refresh"| A[platform-api]
W -->|"RSC и route handlers: Bearer из cookie"| A
W -->|"rewrite /api/v1/*"| A
A -->|"/api/v1/services/{service}/…"| S[Control Plane, сервисы пакетов]
| Слой | Что делает |
|---|---|
| middleware | маршрутизация локали (/ru/…, /en/…), проверка сессии, silent refresh |
| серверные компоненты (RSC) | читают access token из cookie и зовут platform-api напрямую по NEXT_INTERNAL_API_URL |
route handlers /api/* |
вход и выход, прокси модуля Control Plane (/api/cp/*), сессия харнесса консоли, SSE уведомлений |
rewrite /api/v1/* |
браузерные запросы к API панели проксируются на NEXT_INTERNAL_API_URL |
Принципиальное свойство: Bearer не попадает в JavaScript браузера. Токены живут в httpOnly-cookie, а все обращения к сервисам с токеном выполняет сервер Next.js.
Сборка и параметры¶
NEXT_PUBLIC_* — значения времени сборки¶
Next.js вшивает переменные с префиксом NEXT_PUBLIC_ в клиентский бандл
при docker build. Задать их в environment контейнера недостаточно:
сервер увидит новое значение, а браузер — старое из бандла. Поэтому в
compose.yml они передаются как build.args:
| Build-arg | Значение в compose | Назначение |
|---|---|---|
NEXT_PUBLIC_API_URL |
${TAIMEN_PUBLIC_URL}/platform |
публичная база platform-api |
NEXT_PUBLIC_KEYCLOAK_URL |
${TAIMEN_PUBLIC_URL}/auth |
публичный адрес Keycloak |
NEXT_PUBLIC_KEYCLOAK_REALM |
platform |
realm |
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID |
platform-web |
публичный PKCE-клиент |
NEXT_PUBLIC_APP_URL |
${TAIMEN_PUBLIC_URL} |
база redirect_uri (…/auth/callback) |
NEXT_PUBLIC_CP_TIMEZONE |
${CP_TIMEZONE:-Europe/Moscow} |
IANA-зона для времени в экранах модулей |
NEXT_INTERNAL_API_URL |
http://platform-api:8000 |
цель rewrite /api/v1/* (запекается в routes-manifest) |
Секретов в NEXT_PUBLIC_* быть не должно — они видны любому, кто открыл
консоль.
Смена публичного адреса требует пересборки
Изменили TAIMEN_PUBLIC_URL, realm, клиент или часовой пояс — пересоберите
образ: docker compose --profile platform build platform-web && docker compose --profile platform up -d platform-web.
Перезапуска контейнера недостаточно.
Часовой пояс и ошибка гидратации
Время в экранах модулей форматируется и на сервере, и в браузере. Если
зона сервера и зона бандла расходятся, текст ячеек различается, и React
падает с ошибкой гидратации (minified error #418). Поэтому
NEXT_PUBLIC_CP_TIMEZONE задаётся и как build-arg, и в environment
контейнера — одним значением.
Переменные времени выполнения¶
| Переменная | Значение в compose | Назначение |
|---|---|---|
NEXT_INTERNAL_API_URL |
http://platform-api:8000 |
адрес platform-api для серверных вызовов |
NEXT_INTERNAL_KEYCLOAK_URL |
http://keycloak:8080/auth |
адрес Keycloak для обмена кода на токены (по умолчанию — публичный) |
NEXT_PUBLIC_APP_URL |
${TAIMEN_PUBLIC_URL} |
для серверного рендера |
CP_SERVICE_KEY |
control-plane |
ключ сервиса Control Plane в шлюзе |
CP_PROJECT_ID |
— | необязательный фокус консоли на одном проекте |
CP_TIMEOUT_MS |
10000 |
таймаут запроса к шлюзу |
NEXT_PUBLIC_CP_CONSOLE_FORCE |
— | 1 — показать модуль без флага и ролей; работает только при NODE_ENV != production |
Образ¶
Многостадийный Dockerfile (Node 20, output: standalone): зависимости →
сборка → runtime под непривилегированным пользователем nextjs (uid 1001),
порт 3000, healthcheck — HTTP-запрос к 127.0.0.1:3000. Порт на хосте —
127.0.0.1:${PLATFORM_WEB_HOST_PORT:-13000}; снаружи консоль отдаёт Caddy
на корне сайта.
Локальная разработка¶
cd platform-web
cp .env.example .env.local # адреса API и Keycloak
npm ci
npm run dev # http://localhost:3001
npm run type-check && npm run lint && npm test
npm run i18n:check # полнота каталогов сообщений ru/en
Dev-сервер слушает порт 3001 — этот адрес разрешён в шаблоне realm для
клиента platform-web (см. Keycloak → Клиенты).
Сессия¶
Способы входа¶
| Способ | Поток |
|---|---|
| Форма e-mail + пароль (основной) | браузер → POST /api/auth/login (route handler) → platform-api POST /api/v1/auth/login → password grant в Keycloak |
| Вход через Keycloak (PKCE) | редирект на Keycloak с code_challenge (S256) → GET /auth/callback?code=&state= → проверка state → обмен кода на токены |
В обоих случаях результат — две httpOnly-cookie:
| Cookie | Содержимое | Срок |
|---|---|---|
platform_access_token |
access token Keycloak | expires_in токена (5 минут) |
platform_refresh_token |
refresh token | 1800 с |
pkce_code_verifier, oidc_state |
временные значения PKCE-потока | до завершения входа |
Флаги: httpOnly, SameSite=Strict, Path=/, Secure везде, кроме
NODE_ENV=development. Параметр next (куда вернуться после входа)
принимается только как относительный путь — абсолютные и //-адреса
отбрасываются (защита от open redirect).
Silent refresh¶
Middleware проверяет сессию на каждом навигационном запросе (кроме статики,
/auth/callback и /api/*):
flowchart TD
R[Запрос страницы] --> L{Страница входа?}
L -->|да| P[пропустить]
L -->|нет| T{Есть access token?}
T -->|нет| RT{Есть refresh token?}
RT -->|нет| LOGIN[редирект на вход, cookie очищены]
RT -->|да| RF[POST /api/v1/auth/refresh]
T -->|да| E{Истекает в ближайшие 30 с?}
E -->|нет| P
E -->|да| RF
RF -->|успех| OK[новые cookie в ответе И в заголовке Cookie запроса]
RF -->|400/401/403| LOGIN
RF -->|429, 5xx, сеть| TR[временный сбой: сессия не закрывается]
Три свойства, от которых зависит стабильность сессии:
- Обновлённые токены видны текущему запросу. После успешного refresh
middleware не только ставит
Set-Cookieв ответ, но и подменяет заголовокCookieсамого запроса. Серверные компоненты того же запроса читают cookie из заголовков — без подмены первая навигация после истечения токена ушла бы вниз без токена, и пользователя выбросило бы на вход. - Сессию закрывает только окончательный отказ. Ответы
400,401,403от refresh означают, что refresh token отвергнут — cookie очищаются, пользователь идёт на вход (сerror=deactivatedилиerror=not_foundдля соответствующих кодов).429,5xxи сетевые ошибки — временный сбой: если access token ещё действует, запрос пропускается как есть; если его нет, пользователь попадает на страницу входа сerror=service_unavailable, но cookie сохраняются. - Лимиты считаются по браузеру. Refresh уходит в platform-api с
X-Forwarded-Forисходного запроса, поэтому лимит группыauth(RATE_LIMIT_AUTH_PER_IP, 20 в минуту) делится по пользователям, а не по контейнеру веба (см. platform-api → Rate limit).
Route handlers /api/* middleware не обслуживает: прокси модулей сами
проверяют срок access token и при необходимости обновляют его по тому же
правилу, возвращая новые cookie в ответе.
Выход¶
POST /api/auth/logout → platform-api POST /api/v1/auth/logout (logout в
Keycloak по сохранённому refresh token, best effort — ошибка бэкенда выход не
блокирует) и безусловная очистка cookie.
Серверные прокси модулей¶
Модуль Control Plane¶
Браузер не ходит в шлюз напрямую. Мутации и клиентские чтения идут в
route handler /api/cp/<path>, который:
- берёт access token из cookie (обновляя его при необходимости);
- проверяет метод и путь по allow-list — чтения по известным коллекциям,
записи перечислены по одной; всё остальное —
404; - пробрасывает только
If-MatchиIdempotency-Keyиз клиентских заголовков (Authorizationклиента отбрасывается); - отправляет запрос в
{NEXT_INTERNAL_API_URL}/api/v1/services/{CP_SERVICE_KEY}/api/v1/<path>.
Серверные страницы модуля вызывают тот же шлюз напрямую. Ошибки шлюза
(конверт платформы с error_code) и ошибки Control Plane (его конверт
{error: {code, …}}) сводятся к одной ошибке клиента; 401 на серверной
странице — редирект на вход с next.
Отдельный route handler /api/cp-session держит сессию харнесса консоли:
claim задачи в Control Plane требует sessionId, поэтому консоль открывает
сессию под identity пользователя, хранит её id в httpOnly-cookie
cp_web_session и продлевает heartbeat'ом, пока вкладка открыта (TTL 300 с).
Модули вертикальных пакетов устроены так же: прокси /api/<пакет>/* к
/api/v1/services/{KEY}/… со своим allow-list и ключом сервиса в окружении.
Уведомления (SSE)¶
/api/notifications/stream проксирует поток
/api/v1/users/me/notifications/stream platform-api: браузерный
EventSource не умеет ставить заголовок Authorization, поэтому токен
подставляет сервер. Прокси передаёт request.signal вверх по цепочке, так
что закрытая вкладка закрывает и поток на бэкенде.
Видимость модулей¶
Модуль продукта показывается в сайдбаре и открывается, только если выполнены оба условия:
| Условие | Модуль Control Plane |
|---|---|
| feature flag tenant'а | control_plane_console |
| роль пользователя | org_admin или platform_admin |
Нет флага — экран «Модуль не включён»; нет роли — «Недостаточно прав». Флаг
по умолчанию выключен (baseline enabled: false).
Эффективные флаги текущего пользователя:
curl -s https://platform.example.com/platform/api/v1/users/me/feature-flags \
-H "Authorization: Bearer $KC_TOKEN"
# {"flags": {"control_plane_console": false, …}}
Включить модуль для tenant'а (право tenant_settings:write, есть у
org_admin; tenant берётся из токена):
curl -s -X PATCH \
https://platform.example.com/platform/api/v1/admin-config/feature-flags/control_plane_console \
-H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \
-d '{"enabled": true}'
# {"flag_code": "control_plane_console", "enabled": true, "source": "tenant_override", …}
| Операция | Эндпоинт |
|---|---|
| список флагов tenant'а с источником значения | GET /api/v1/admin-config/feature-flags |
| установить override | PATCH /api/v1/admin-config/feature-flags/{flag_code} с {"enabled": bool} |
| снять override (вернуть baseline) | DELETE /api/v1/admin-config/feature-flags/{flag_code} — идемпотентно |
Незарегистрированный код флага — 404. Изменение пишется в журнал аудита
tenant'а.
Флаг — не право доступа
Флаг и роль решают только, показывать ли модуль. Что пользователь может делать внутри, решает сервис за шлюзом: у Control Plane — binding IAM-principal'а человека (см. platform-api → Подключение человека). Нет права на отдельный эндпоинт — пустеет одна панель экрана с подписью «нет права», а не весь экран.
Локализация¶
Маршруты начинаются с кода локали: ru (по умолчанию) или en. Путь без
локали перенаправляется (307) на локаль, выбранную по cookie NEXT_LOCALE
и заголовкам браузера; короткий сегмент, похожий на неподдерживаемый код
локали (/fr/…), даёт 404. Каталоги сообщений — messages/ru-RU.json и
messages/en-US.json, у модулей — свои каталоги; полноту проверяет
npm run i18n:check.
Обновление¶
# после изменения исходников веба или NEXT_PUBLIC_* параметров
docker compose --profile platform build platform-web
docker compose --profile platform up -d platform-web
Пересборка веба не затрагивает активные сессии: cookie остаются в браузере, а токены проверяет platform-api.
Типичные проблемы¶
| Симптом | Причина | Что делать |
|---|---|---|
| После входа сразу снова экран входа | cookie не ставятся: Secure по http вне development, или домен консоли отличается от NEXT_PUBLIC_APP_URL |
работать по https; проверить публичный адрес |
| Выбрасывает на вход через несколько минут | refresh token отвергается (SESSION_REFRESH_FAILED, USER_DEACTIVATED) или SSO-сессия Keycloak истекла |
журнал platform-api, настройки сессий realm |
| Ошибка гидратации React #418 | расходятся часовые пояса сервера и бандла | задать CP_TIMEZONE, пересобрать образ |
| Редирект Keycloak «Invalid redirect_uri» | NEXT_PUBLIC_APP_URL не совпадает с Valid Redirect URIs клиента platform-web |
поправить клиента в realm или пересобрать веб |
| Модуль не виден | флаг выключен или у пользователя нет org_admin/platform_admin |
Видимость модулей |
Частые 429 у всех |
серверные вызовы не пробрасывают адрес клиента, неверный TRUSTED_PROXY_COUNT |
platform-api → Rate limit |