TokenSync.tsx был удалён в Phase 1a (keycloak-js migration, MR !270).
В JSDoc на RequireAuth.tsx осталась ссылка на его поведение —
обновляем описание под текущую модель: apiClient request interceptor
+ ensureFreshToken на 401 (см. api/client.ts:64).
Минор-долг из checkpoint'а keycloak-js-altum-migration.
Frontend default: https://auth.nstart.space/realms/nstart →
https://altum.nstart.cloud/auth/realms/altum. Shared SSO с altum portal.
Keycloak setup (Keycloak admin actions требуются ДО merge):
- В realm `altum` создать client `ordinis` (тип: public, standard flow)
- Redirect URIs:
https://ordinis.nstart.cloud/*
https://ordinis.k8s.265.nstart.cloud/*
http://localhost:5173/*
- Web origins: те же
- Client-roles: ordinis-public, ordinis-internal, ordinis-restricted,
admin (admin даёт RESTRICTED через ScopeContext.isAdminRole)
Backend issuerUri меняется отдельным MR в ordinis-infra
(charts/ordinis/values.yaml).
После настройки Keycloak + деплоя — старый realm auth.nstart.space/nstart
больше не используется. Все юзеры должны иметь учётки в realm altum
(вероятно brokered к nstart-id если SSO с New Start ID настроен).
171/171 tests, TSC чистый.
Phase 1a из плана: только swap библиотеки, realm/URL остаются на
auth.nstart.space/realms/nstart. Phase 2 (intgr с altum auth-service)
— отдельный эпик.
Зачем. oidc-client-ts с automaticSilentRenew триггерил бесконечный
POST /token loop когда refresh_token истекал (см. MR !269 revert,
~30+ 400'к в console, страница залипала на 403). Altum давно ушёл
с oidc-client-ts на keycloak-js по той же причине.
Новая модель.
- src/lib/keycloak.ts — singleton + initKeycloak(check-sso, PKCE) +
ensureFreshToken(N) wrapper над kc.updateToken(). Зеркало
altum/src/lib/keycloak.ts.
- src/stores/authStore.ts — Zustand store: {user, isAuthenticated,
isLoading, error}. checkAuth() ждёт initKeycloak, wireKeycloakEvents()
→ onAuthSuccess/Refresh/Logout/TokenExpired re-снэпшотят store.
- src/auth/useAuth.ts — compat shim для react-oidc-context API
(auth.user.profile, isAuthenticated, signinSilent, signinRedirect,
signoutRedirect, removeUser, error). Все 11 callsites продолжают
работать без правок (только сменили путь import'а).
- src/api/client.ts — request interceptor дёргает ensureFreshToken(30)
ПЕРЕД каждым запросом + attaches Bearer ${getToken()}. 401 interceptor
делает ensureFreshToken(0) + retry один раз. Никаких таймеров, никаких
setAccessToken/setUnauthorizedHandler holder'ов — keycloak-js сам source
of truth.
- src/main.tsx — убран <AuthProvider>, <TokenSync />. Просто
useAuthStore.getState().checkAuth() на старте, потом обычный render.
- public/silent-check-sso.html — endpoint для silentCheckSsoRedirectUri.
Удалены.
- src/auth/TokenSync.tsx — не нужен, ensureFreshToken на запросах.
- src/auth/oidcConfig.ts — не нужен, конфиг внутри lib/keycloak.ts.
- npm deps: react-oidc-context, oidc-client-ts.
Добавлены deps: keycloak-js@26.2.4, zustand@5.0.13.
Tests: 171/171 passed, TSC чистый.
Migration callsites — все unchanged по семантике благодаря compat shim.
Симптом: пользователь видел ~30+ POST на Keycloak /token endpoint
с 400 Bad Request, страница залипала.
Корень. MR !267 добавил useEffect в TokenSync который на
auth.user?.expired === true делал signinSilent → catch → signinRedirect.
MR !268 добавил interceptor триггер на 403 + !accessToken.
Цикл:
1. token expires, refresh_token тоже (Keycloak SSO Session Idle прошёл)
2. useEffect видит expired=true → signinSilent → POST /token с refresh
3. Keycloak возвращает 400 invalid_grant (refresh expired)
4. catch → signinRedirect, но oidc-client-ts automaticSilentRenew=true
параллельно тоже пытается → ещё POST /token → ещё 400
5. signinRedirect не успевает редиректнуть до следующего render'а
6. reauthing.current=false → useEffect fire'ит снова → новый signinSilent
7. → ещё 400, и так в loop ~30 раз пока что-то не разрулит
Откатываю обе ветки. Возвращаемся к v2.42.3 поведению: silent renew
автоматически пробует один раз, на failure пользователь видит 403
(или stale data из cache) и делает relogin вручную. Не идеально, но
не спамит Keycloak и не залипает на endlessly.
Backend ScopeContext admin → RESTRICTED (MR !267 backend часть)
остаётся — это безвредная правка, помогает юзерам с admin ролью
без явного INTERNAL.
Симптом: после ~30 мин админ видит 403 на /admin/*, перелогин чинит.
Цепочка событий:
1. access_token истекает
2. oidc-client-ts автоматический silent renew падает (refresh_token expired
за 30 мин SSO Session Idle / iframe заблокирован)
3. auth.user становится undefined (oidc убирает после full expiry)
4. TokenSync setAccessToken(null) — accessToken в apiClient зануляется
5. Следующий запрос идёт БЕЗ Authorization header
6. Backend (ORDINIS_AUTH_REQUIRED=false) пропускает как anonymous →
ScopeContext = PUBLIC → controller с requireInternal() кидает 403
401-handler не срабатывает на 403 → юзер залипает.
Также мой предыдущий useEffect в TokenSync (MR !267) триггерит на
auth.user?.expired === true, но если user удалён полностью —
optional-chaining вернёт undefined и useEffect не сработает.
Fix: расширил interceptor чтобы на (status === 403 && accessToken == null)
тоже вызывать unauthorizedHandler. Этот handler — single source of
truth, делает signinSilent → fallback signinRedirect.
Не цепляет legitimate 403 (когда у юзера ЕСТЬ токен но scope недостаточно —
например viewer пытается dropить INTERNAL dict). accessToken != null
в таких случаях → ветка не сработает.
Симптом: после ~30 мин неактивности пользователь видит 403 на /admin/audit
и /admin/users. Перелогин решает.
Корень. automaticSilentRenew: true в oidcConfig должен обновлять
access_token, но падает молча когда:
- refresh_token истёк (Keycloak SSO Session Idle = 30 мин)
- silent renew iframe заблокирован 3rd-party cookies
- silentRenewError не подцеплен глобально
В этих случаях user.expired=true, accessToken=null. Дальнейшие запросы идут
БЕЗ Authorization header → backend с ORDINIS_AUTH_REQUIRED=false пропускает
как anonymous → requireInternal() видит PUBLIC scope → 403 (не 401).
401-handler в interceptor'е не срабатывает (статус не тот) → юзер залипает.
Fix. Новый useEffect в TokenSync watches auth.user?.expired. На transition
в true: signinSilent (вдруг refresh ещё жив), на failure signinRedirect.
Anti-loop ref предотвращает повторные попытки пока expired=true.
Бонусом — за компанию с MR !267 (admin → RESTRICTED) — после v2.42.4 деплоя
админ может работать сессией дольше 30 мин без 403'ов.
Backend currentUserId() возвращает литерал "anonymous" когда JWT отсутствует
(ORDINIS_AUTH_REQUIRED=false fallback). Audit / draft entries сохраняют
этот sub в БД. Frontend UserCell дёргал /admin/users/anonymous/display
→ 404 → catch → display "—". Логически ок, но в консоли DevTools мусорный
404 на каждом рендере таблицы.
Skip endpoint когда uuid === "anonymous":
- useResolveUserDisplay: enabled = uuid && uuid !== 'anonymous'
- useUserDisplay: short-circuit ветка возвращает display: 'anonymous'
UX без изменений (раньше показывал «—» с full UUID в tooltip; теперь
показывает «anonymous» без 404 в консоли).
Early-return на TextInput-fallback стоял ДО useMemo(triggerLabel) и
useMemo(selected). React падает «rendered fewer hooks than expected»
когда количество вызовов хуков меняется между рендерами.
Reproduce: loading=true первый рендер → ВСЕ хуки выполнились (включая
triggerLabel). loading=false с data=[] второй рендер → fallback
возвращается до triggerLabel useMemo → меньше хуков → error boundary
ловит → юзер видит «Что-то пошло не так».
Перенёс все useMemo выше early-return. Логика рендера без изменений.
Если /api/v1/admin/users возвращает [] (cache пустой / writer только-что
рестартован / у юзера нет INTERNAL scope → 403 → catch → []), picker
показывал «Пользователи не найдены» и фильтр становился недоступен. До
MR !263 здесь был обычный TextInput, не лишаем юзера возможности вписать
UUID руками.
При isLoading=false, isError=false, data.length===0 — рендерим TextInput
с подсказкой «Список юзеров пуст — введите вручную». Backend cache
прогреется по мере того как юзеры будут делать authenticated requests
(JwtUserCaptureFilter перепишет user_display_cache); тогда picker
включится автоматически.
PeriodFilter рендерил DayPicker numberOfMonths={2} со стандартными
rdp-* стилями — `.rdp-months` имеет `flex-wrap: wrap`, при недостатке
ширины два месяца стакались вертикально, ячейки большие/разреженные,
selected/hover состояний под наш accent не было. Выглядело rough.
Два фикса:
1. PeriodFilter → numberOfMonths={1}. Для audit-фильтрации одного
месяца достаточно (presets покрывают «7д/30д/Этот месяц»; календарь
нужен для arbitrary range). Простой layout, не зависит от ширины
popover'а.
2. Полноценные стили `.ord-day-picker` в styles.css (~80 строк):
- tight cells (2rem × 2rem)
- hover state на surface-2
- selected day → accent bg + on-accent text
- range_middle → accent-bg, обнулённый radius
- range_start/end → polo'асn'ое closure radius
- today → 1px outline
- outside-month days → mute opacity
- nav buttons → accent цвет + hover
- mono font на числа (JetBrains Mono), Inter на caption/weekday
Бенефит распространяется и на DateRangePicker (использует тот же класс).
flex-wrap: nowrap override — на случай если когда-то понадобится
numberOfMonths={2} в другом контексте, не будет вертикального стека.
Заменил голый DateRangePicker (только календарь, требует двух кликов на любой
typical период) на preset-driven PeriodFilter — pattern из Datadog/Grafana:
Triггер компактный (h-9), показывает active preset label либо forматированный
range («04.05.2026 — 29.05.2026»). При клике открывается popover с:
- Список пресетов слева: Сегодня / Вчера / Последние 7 дней / 30 дней /
Этот месяц / Прошлый месяц
- «Произвольный диапазон» внизу — раскрывает 2-month DayPicker справа
Active preset подсвечивается. Custom range детектится sameRange сравнением —
если value матчит preset, его и подсвечиваем. X-кнопка в trigger'е чистит
период без открытия popover'а.
DayPicker lazy-loaded (~80kb gz, не пакуется в initial audit bundle).
ISO bounds в audit.tsx не трогали — onChange отдаёт Date'ы, нормализация
T00:00:00/T23:59:59 + local TZ остаётся прежней.
Файлы:
- components/audit/PeriodFilter.tsx (новый, ~190 строк)
- routes/audit.tsx — заменил DateRangePicker → PeriodFilter, убран import
171/171 tests, TSC чистый.
1. DateRangePicker popover. PopoverContent имел default w-72 (288px),
а DayPicker с numberOfMonths={2} требует ~560px. Из-за этого второй
месяц переносился под первый, popover вылазил поверх таблицы аудита
и кнопок «Сбросить/Применить». Override на p-3 w-auto max-w-[min(95vw,640px)]
— два месяца теперь side-by-side, popover floats как должно.
2. Панель фильтров /audit. Было: 3-col grid с 4 input'ами + DateRange
span'ит 2 cols на отдельной строке. Стало: на xl (≥1280px) все 5
полей inline в одну строку (lg → 3 cols, sm → 2, mobile → 1).
Padding p-4 space-y-3 → p-3 space-y-2. Убран border-t на action row
— визуальный шум.
Изменения только на /audit (date-range-picker — shared компонент,
но используется только в audit-фильтрах сейчас; рендер других мест
не страдает — popover просто шире, w-auto не ломает узкий контент).
Цифра у иконки словарей показывала общее число справочников (~40 на проде).
Не меняется в течение работы юзера — не actionable, только визуальный шум.
Actionable badges остаются: «Мои черновики», «На ревью», «Outbox», «Webhooks» —
там цифра реально что-то означает (надо что-то сделать или разобраться).
Заодно убрал unused dictsQuery / dictsCount + import useDictionaries из обоих
сайдбаров (desktop + mobile). TSC noUnusedLocals чистый.
Badge в сайдбаре (desktop + mobile) показывал totalElements от /drafts/me
и /admin/schema-drafts/me — это сумма ВСЕХ статусов (PENDING + APPROVED +
REJECTED + WITHDRAWN). После approve/reject счётчик не уменьшался, юзер
видел «висящие» цифры на драфтах которые уже terminal.
Tab-бейдж в ReviewsPage давно делает правильно через classifyRecordStatus
/ classifySchemaStatus. Подтягиваем sidebar к той же логике:
- fetch size 50 вместо 1 (нужны .items[] для filter; TanStack дедуплицирует
query — на /reviews этот же ключ уже зовётся, лишних запросов нет)
- record → 'pending', schema → 'pending'|'wip'
- approved schema всё ещё попадает в pending по дизайну (maker должен
publish'нуть; см. classifySchemaStatus комментарий)
В collapsed-режиме (56px полоска) рендерили только иконку без label
и без badge — счётчик pending записей/драфтов был не виден, юзер не
понимал что есть необработанные элементы пока не раскроет сайдбар.
Теперь в collapsed: badge → маленький круглый pill в углу иконки
(top-right, 16×16, ring-1 от bg чтобы хорошо отделялся от иконки).
Cap "99+" если число большое — uniform width. Tooltip на link
включает label + (count) при hover.
Expanded-mode без изменений — badge справа от label как был.
1. useApproveDraft/useRejectDraft теперь инвалидируют ['drafts'] key
(my-drafts + by-dict lists). До этого инвалидировался только ['draft']
(single by id), поэтому after-approve "Мои" badge продолжал показывать
pending-счётчик даже когда все драфты уже APPROVED — useMyDrafts отдавал
стейл-кеш. useWithdrawDraft уже это делал, теперь подтянули approve/reject.
2. DictionaryEditorDialog: на десктопе (≥880px) modal теперь max-w 1400px
вместо max-w-5xl (880px после маппинга в SIZE_CLASSES.xl). На <880px
осталось w-[95vw] (compact mobile modal). На 1440p мониторе editor
больше не выглядит вжатым, tabs+schema+preview помещаются без скролла.
Override через panelClassName c `!` чтобы перебить SIZE_CLASSES.
Три проблемы по reviews-drawer (по фидбеку: «Открыть ломается / Согласование
не показывает справочник / не показывает live / JSON не подсвечен»):
1. useLiveRecord(draft.dictionaryId, ...) → useLiveRecord(dictName, ...).
Endpoint /api/v1/dictionaries/{name}/records/{bk} ожидает name, не UUID;
до фикса валился с 404 «Dictionary not found: <uuid>» → live-блок был
пустой и показывался "noLive" даже для UPDATE/CLOSE drafts.
2. Header drawer'а теперь рендерит "<dictName> / <businessKey>" вместо
просто businessKey. dictName — link на /dictionaries/$name. Fallback
на UUID-prefix без link'а если dict не виден (scope-hide).
3. JsonView (новый компонент, ~120 строк, без deps) заменяет голые <pre>
JSON.stringify(...):
- syntax highlight: ключи (aurora), строки (pink), числа (warn),
true/false/null (accent), пунктуация (mute)
- diff highlight: top-level ключи из shallowDiffSummary получают
фон+border (зелёный added / красный removed / жёлтый changed).
В live-колонке — removed+changed, в proposed — added+changed.
Также добавлен error-state для liveQ (раньше 404 от useLiveRecord на UUID
молча превращался в "noLive"; теперь explicit "не удалось загрузить").
В таблицах /reviews (Запись + Мои → Запись) колонка «Объект» показывала
UUID-prefix («254a662f…/REFLEXIO-OPTIC») вместо имени словаря, а ссылка
вела на /dictionaries/<uuid> — 404 (route ожидает name, не UUID).
DraftResponse несёт только dictionaryId (UUID); dictionaryName есть только
у schema-drafts. Резолвим через useDictionaries() — кешируется TanStack
Query, дополнительных запросов не делает.
Fallback на UUID-prefix без link'а, когда dict не в выдаче (scope-hide /
удалён) — лучше чем ведущая в 404 ссылка.
Files:
- routes/reviews.tsx — records-tab table (queue, ReviewsPage)
- components/reviews/MyDraftsPanel.tsx — RecordRow ("Мои" tab)
Три массива в instrument были comma-separated текстом с подсказкой
«businessKey'и из X». Пользователь не знал что туда вписать — справочника
доступных значений рядом нет.
UI: добавлен ReferenceMultiSelectField — array-аналог ReferenceSelectField.
Триггер — `items["x-references"]` на string-массиве. Под капотом — готовый
MultiSelect (popover + cmdk-поиск). Fallback на comma-separated TextInput
сохраняется когда target dict недоступен (scope-hide / 403 / 404), сервер
всё равно валидирует FK через ReferenceValidator.
Schema:
- spacecraft_codes → items.x-references: spacecraft.designator
- supported_format_codes → items.x-references: data_format.code
- supported_level_codes → items.x-references: processing_level.code
instrument schemaVersion 1.0.0 → 1.1.0, bundle 1.3.2 → 1.3.3 —
CuodBundleImporter перепишет schema_json в БД на старте writer'а,
admin-ui подхватит свежую схему через /api/v1/dictionaries/instrument.
Tests: 2 новых кейса в SchemaDrivenForm.test.tsx — happy path (MultiSelect
рендерится, запрос ушёл на правильный dict) + fallback (comma-separated
с подсказкой «список недоступен»). 171/171 passed.
1. reviews.tsx: ReviewDrawer звал useLiveRecord для ЛЮБОГО черновика,
включая CREATE. У CREATE-черновика live-записи нет по определению →
GET /dictionaries/{id}/records/{key} → 404 (красный шум в консоли на
каждый CREATE-draft). Теперь для CREATE запрос не запускается, блок
«текущая версия» показывает «нет текущей версии».
2. modal.tsx: Modal рендерил DialogTitle только при наличии prop title.
Без title Radix писал в консоль «DialogContent requires a DialogTitle»
+ warning про aria-describedby. Теперь Modal всегда рендерит DialogTitle
(sr-only fallback) и гасит aria-describedby когда описания нет.
Принцип: клик по «Сохранить» не должен оставаться без реакции.
Раньше submit мог молча оборваться: AJV-ошибка ложилась через setError
на nested-путь (data.<field>.<locale>), а FieldError читает data.<field>
→ ошибка невидима, форма «не реагирует».
Добавлено:
- blockedMsg — состояние с причиной, рендерится видимым Alert сверху
формы при любой блокировке submit (validFrom/validTo, AJV).
- onInvalid — обработчик RHF handleSubmit на случай когда RHF сам
блокирует submit (required-поля); тоже ставит blockedMsg.
- scrollIntoView к началу формы — Alert гарантированно в зоне видимости.
Теперь Save либо сохраняет, либо показывает «Не удалось сохранить —
проверьте поля: …». Молчания не будет.