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.
Reported 2026-05-14: editing schema on an approvalRequired dictionary
applied inline without going through the schema-draft maker-checker
workflow. Two endpoints both lacked the gate that DictionaryRecordService
already had for record CRUD:
1. PUT /api/v1/admin/dictionaries/{name}
(frontend SchemaDrivenForm "edit schema" save button hits this).
DictionaryDefinitionService.updateSchema overwrote schemaJson directly.
Bonus backdoor: same request could simultaneously flip
approvalRequired:true→false and rewrite the schema in one transaction
— review-bypass via toggle.
2. PATCH /api/v1/admin/dictionaries/{name}/schema (inline non-breaking
patch — description / title / minimum / maximum / enum-add). Frontend
doesn't currently call this, but backend exposed it without a gate.
Both now reject with `422 approval_required` carrying the schema-drafts
URL in the message. Frontend's existing serverErrorMessage in
DictionaryEditorDialog already surfaces backend `message` to the user,
so no UI changes needed for correctness. UX polish (hide "Save" /
show "Create draft" CTA when approvalRequired=true) is a follow-up.
The PUT gate is narrow on purpose: schemaChanged OR approvalRequired
toggle-off. Pure metadata edits (display name, description, locales,
projection flag) on an approvalRequired dict still go through inline
— those don't affect record validation, no review needed.
Implements the Phase 2 hook from the previous commit. With this in
place, UserDisplayService.find chains:
hot cache → DB (user_display_cache) → Keycloak Admin API
so any realm user resolves on first display, even if they never made
an authenticated request to ordinis. The resolved entry is persisted
via UserDisplayService.upsert with source=KEYCLOAK_ON_DEMAND, so the
next render of the same sub hits the DB tier (~ms) instead of going
back to Keycloak.
Implementation notes:
- Spring RestClient (modern blocking HTTP, replaces RestTemplate).
- client_credentials grant against
{issuer}/realms/{realm}/protocol/openid-connect/token. Token cached
in-memory minus a 30s safety margin from `expires_in`.
- 401 on user lookup wipes the cached token and surfaces a
RuntimeException, so the next resolve refetches.
- 404 on user lookup → Optional.empty (definitive miss, sub not in
realm). UserDisplayService logs and the caller falls back to short
UUID. Negative cache TTL not implemented — added complexity not
justified for the current realm size (~50 users).
- Bean activated only when ordinis.keycloak.admin.enabled=true AND
the env vars are set. Default disabled — UserDisplayService treats
KeycloakUserResolver as @Autowired(required = false), so a
deployment without Keycloak admin creds keeps the previous behavior
(DB cache only) instead of crashing on startup.
Application config:
- ORDINIS_KEYCLOAK_ADMIN_ENABLED (false by default)
- ORDINIS_KEYCLOAK_ADMIN_URL e.g. https://auth.nstart.space
- ORDINIS_KEYCLOAK_ADMIN_REALM defaults to nstart
- ORDINIS_KEYCLOAK_ADMIN_CLIENT_ID via vault (see docs-internal/keycloak-admin-setup.md)
- ORDINIS_KEYCLOAK_ADMIN_CLIENT_SECRET via vault
Setup runbook (docs-internal/keycloak-admin-setup.md) covers:
- creating the Keycloak service-account client + assigning view-users
- writing the secret into both vault instances (different per env)
- wiring vault.hashicorp.com agent-inject annotations into the chart
- verification steps + common failure modes
Phase 3 (scheduled bulk sync) is documented at the end of the runbook
but deferred — depends on observed cache miss rate after this lands.
Closes "ток мой UUID отображается" pain reported on staging today: any
reviewer / maker / publisher / actor whose JWT sub never landed in the
JVM cache shows up as a short UUID in audit / reviews / changelog /
events / history / record drawer / webhook detail / record version
history. Restart the pod and even captured users disappear.
Three-tier resolve, replacing the in-memory-only cache that was the
"Альтернатива (на будущее)" TODO in the original UserDisplayService:
1. JVM ConcurrentHashMap hot cache (~µs, capped at 10k).
2. user_display_cache Postgres table (~ms, persistent across restart).
New migration 0023 — sub PK + preferred_username + name + email +
source enum + synced_at + updated_at, plus an index on synced_at
for the scheduled bulk sync that lands later.
3. Keycloak Admin API on-demand (Phase 2) — dispatched to optional
KeycloakUserResolver bean. When the bean isn't wired (no Keycloak
admin creds yet), the third tier is a no-op and UserCell falls
back to short UUID exactly like before.
Source enum on every row — JWT_CAPTURE / KEYCLOAK_SYNC /
KEYCLOAK_ON_DEMAND — so the eventual scheduled sync can prefer JWT-
captured rows (richest claims as the user actually saw them) and the
on-demand fallback can be distinguished from bulk sync in audits.
JwtUserCaptureFilter and UserDisplayController are unchanged — they
already used UserDisplayService.put / find with the same signatures.
The behavior change is invisible at the API layer: same /admin/users/
{sub}/display endpoint, same 404 user_not_cached on miss; persistence
just survives pod restart now.
mergeFrom on UserDisplayCacheEntry deliberately keeps the existing
non-blank field when an update brings null — Keycloak sync may have a
richer email than JWT capture; don't blank it out.
Phase 2 will land a KeycloakAdminUserResolver impl + vault secret
plumbing on this same branch. Phase 3 (scheduled bulk sync via
@Scheduled) is a separate sprint — it needs the Keycloak admin client
already in place.
После !178 (record drawer + RecordHistoryDrawer + webhook detail) ещё
осталось 6 точек где раньше рендерился UUID. Все они в "истории
словаря" и tab "События":
1. ChangelogDiffModal:57 — header "автор: <name>" в diff modal.
Было: `{data.publishedBy.name}` (тип ChangelogPublisher.name —
docstring буквально говорит "Backend пока эхает sub").
Стало: <UserCell uuid={data.publishedBy.sub} />.
2. TimeTravelModal:686 — список изменений, имя автора рядом с датой.
3. TimeTravelModal:898 — RIGHT panel "История этой версии", dt/dd
pair с автором.
4. TimeTravelModal ComparePanel:414 — "· {author}" suffix,
author там DictionaryDetail.updatedBy (тоже UUID).
5. dictionaries.$name.tsx:2263 (events tab, EventsTabContent row) —
`{r.userId ?? 'anonymous'}` raw. Сменил на UserCell для не-null
userId, anonymous-fallback оставил без UserCell (нечего lookup'ать).
6. dictionaries.$name.tsx:2399 (history tab, HistoryTabContent entry)
— `{entry.publishedBy.name}` raw.
Всё через тот же useUserDisplay hook что и в !177 — backend
endpoint /admin/users/{sub}/display + JwtUserCaptureFilter cache.
Если sub нет в cache, UserCell fallback'ит на shortened UUID
(текущее поведение из !177).
ChangelogPublisher type comment в src/api/client.ts уже намекал что
.name это placeholder — useUserDisplay теперь реально резолвит.