Перейти к содержанию

Веб-консоль

Веб-консоль (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[временный сбой: сессия не закрывается]

Три свойства, от которых зависит стабильность сессии:

  1. Обновлённые токены видны текущему запросу. После успешного refresh middleware не только ставит Set-Cookie в ответ, но и подменяет заголовок Cookie самого запроса. Серверные компоненты того же запроса читают cookie из заголовков — без подмены первая навигация после истечения токена ушла бы вниз без токена, и пользователя выбросило бы на вход.
  2. Сессию закрывает только окончательный отказ. Ответы 400, 401, 403 от refresh означают, что refresh token отвергнут — cookie очищаются, пользователь идёт на вход (с error=deactivated или error=not_found для соответствующих кодов). 429, 5xx и сетевые ошибки — временный сбой: если access token ещё действует, запрос пропускается как есть; если его нет, пользователь попадает на страницу входа с error=service_unavailable, но cookie сохраняются.
  3. Лимиты считаются по браузеру. 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>, который:

  1. берёт access token из cookie (обновляя его при необходимости);
  2. проверяет метод и путь по allow-list — чтения по известным коллекциям, записи перечислены по одной; всё остальное — 404;
  3. пробрасывает только If-Match и Idempotency-Key из клиентских заголовков (Authorization клиента отбрасывается);
  4. отправляет запрос в {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

См. также