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.
Три проблемы по 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)
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 когда описания нет.
Acts on the two "build now" items from the design review of the approval
workflow UX (other five went to TODOS.md). Plus the TODOS.md file itself,
authored during the same review.
1. **Inline "Создать черновик с моими изменениями" CTA**
(DictionaryEditorDialog + CreateSchemaDraftModal)
Was: when a maker edited an approval-required dict, Save disabled and a
warning told them to "close the editor and click 'Создать черновик схемы'
on the dict page." Three friction steps and a hunt for a button.
Now: the warning Alert carries an action button — "Создать черновик с
моими изменениями" — that opens CreateSchemaDraftModal in-place with the
maker's in-progress schemaJson pre-loaded. They add a reason and submit.
No retyping, no hunt.
Implementation:
- CreateSchemaDraftModal accepts optional `initialProposedSchema` prop.
Seed order: edit-mode draft → caller pre-fill → live HEAD.
- DictionaryEditorDialog mounts the modal inline and triggers it from the
Alert action. Closes the editor on successful create.
2. **Diff summary chips in ReviewDrawer**
Was: two raw JSON dumps side by side. Reviewer had to eyeball-diff long
records to find the actual edit.
Now: a row of summary chips above the panes — "+N added, −M removed,
~K changed" with the field names inline. Computed shallow client-side
(shallowDiffSummary helper) — ordinis records are flat-ish at the top
level, no need for a backend diff endpoint. Skipped for CLOSE op (no
proposed) and when both sides are null.
3. **Tightened drawer header + trimmed empty-state copy**
Was: single flex-wrap line with four spans (badge + bk + maker + ts) —
visually busy. Plus a verbose empty-state description: "Когда maker
отправит schema draft на ревью, он появится здесь."
Now: two-row layout (identity row on top, provenance row below) so the
eye lands on Badge + BK first. Empty-state copy shortened to "Очередь
пуста" / "Схемы на ревью появятся здесь."
Other five gaps (Мои tab, full state-coverage pass, DESIGN.md, a11y audit,
maker notifications) recorded in TODOS.md.
MR !177 added /admin/users/{sub}/display + UserCell + useUserDisplay,
but only some renderers were migrated. Three places still rendered
raw UUID strings:
1. reviews.tsx:466 — record draft drawer ("Согласование draft" header)
showed `{draft.makerId}` raw. Tables on the same page already use
<UserCell uuid={d.makerId} />. Verified via prod staging:
soloviev.da approved a record draft today and the drawer header
showed the maker as "77ec2cc8-98e3-4d70-8037-94038fcbde67" instead
of the username.
2. RecordHistoryDrawer.tsx:110 — version history `updatedBy` raw.
3. webhooks.$id.tsx:235 — webhook subscription `createdBy` raw.
All three switched to <UserCell uuid={...} />, matching the existing
pattern. Added the import where missing. Wrapped each in
inline-flex so the UserCell sits next to the label cleanly.
No backend / API changes — same /admin/users/{sub}/display endpoint
populated by JwtUserCaptureFilter (MR !177).
Browser QA на staging показал что UUID actor'а render'ится raw во многих
местах кроме drawer (MR !158 — там был inline fix):
- /reviews Records tab — column AUTHOR
- /reviews Schemas tab — column AUTHOR
- /audit log — column USER
77ec2cc8-98e3-4d70-8037-94038fcbde67 — нечитаемо для оператора. Тот же
паттерн что я делал в MR !158, теперь shared helper:
src/lib/useUserDisplay.tsx:
- useUserDisplay(uuid) hook — returns { display, isMe, fullId }
- UserCell component — readonly cell с title=fullId tooltip
- Match current user (JWT sub claim) → 'Я • <preferred_username>'
- Other user → short UUID (8 chars) + full в title
- null/undefined → 'anonymous'
Применено:
- reviews.tsx:334,625 — records + schemas table AUTHOR cells
- audit.tsx:476 — USER cell
- SchemaDraftDrawer.tsx — refactor (был inline в MR !158 → теперь shared)
TODO Phase 1d: backend endpoint /admin/users/{sub}/display чтобы и для
не-current-user отображать username.
Phase 4 last optional UX из design doc — bulk operations:
- Multi-select column в reviewer queue (Checkbox per row + select-all
header). Set<draftId> selection state, persisted между refetch'ами.
- "Approve выбранные" — confirm dialog → parallel approveDraft mutations
через Promise.all. Каждая mutation independent: если одна fail
(409 self_approve_forbidden / draft_not_pending race), остальные
продолжают.
- "Reject выбранные" — Modal с TextArea для shared reason (required,
matches backend reject_reason_required validation). Reason одинаковый
для всех — bulk обычно "duplicate registration" / "wrong scope" / etc.
- Bulk result Alert: approved count / rejected count / failed list с
per-draft reason'ами (err code из 409). User видит точно что прошло
и что нет.
- Auto-clear selection после bulk operation. Queue refetch'ит через
refetchInterval=30s, плюс mutation onSuccess invalidate'ит ['drafts'].
i18n RU/EN: 22 keys (selectAll/selectRow/selectedCount/approveSelected/
rejectSelected/clear/confirmApprove/rejectModal*/confirmReject/cancel/
resultTitle/approvedCount/rejectedCount/failedCount). Pluralization
через i18next plurals.
Why ship without soak data:
- Implementation не зависит от queue depth — это просто loop'ит над
выбранными drafts.
- Reviewer на любом dict с queue >5 уже выгадывает время.
- Если queue staying small после soak — feature не bothering нас (UI
toolbar показывается только когда anySelected).
Tests: vitest 89/89, tsc clean, prod build clean.