Merge branch 'fix/admin-ui-critical-review-issues' into 'main'

fix(admin-ui): top-5 critical review bugs (auth + AJV + TZ + idempotency + storage)

See merge request 2-6/2-6-4/terravault/ordinis!7
This commit is contained in:
Александр Зимин
2026-05-10 16:22:35 +00:00
8 changed files with 292 additions and 72 deletions
+50 -4
View File
@@ -1,5 +1,41 @@
import axios from 'axios' import axios from 'axios'
/**
* Source of truth для access token. {@link TokenSync} обновляет это значение
* синхронно при изменении OIDC user state. Interceptor читает из in-memory
* holder'а — никаких {@code defaults.headers}, никаких {@code localStorage} poll'ов.
*
* <p>Зачем in-memory holder, не localStorage:
* <ul>
* <li>Race-free: interceptor и TokenSync видят один и тот же token immediately
* после {@code setAccessToken}, без ожидания useEffect cycle.</li>
* <li>Logout: {@code setAccessToken(null)} → следующий request immediately
* без Authorization, без zombie-header в {@code apiClient.defaults}.</li>
* <li>Security: access token не сохраняется в {@code localStorage} (XSS
* persistent reach). См. также {@code oidcConfig.userStore} → sessionStorage.</li>
* </ul>
*/
let accessToken: string | null = null
export const setAccessToken = (token: string | null): void => {
accessToken = token
}
export const getAccessToken = (): string | null => accessToken
/**
* Handler для 401 → silent renew или redirect. Регистрируется {@link TokenSync}
* один раз. Все 401 проходят через единственный interceptor ниже —
* двойной handler (как раньше: один в client.ts, второй в TokenSync) приводил
* к race signinSilent vs signinRedirect и redirect loop.
*/
export type Unauthorized401Handler = (error: unknown) => void
let unauthorizedHandler: Unauthorized401Handler | null = null
export const setUnauthorizedHandler = (h: Unauthorized401Handler | null): void => {
unauthorizedHandler = h
}
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE ?? '/api/v1', baseURL: import.meta.env.VITE_API_BASE ?? '/api/v1',
timeout: 10_000, timeout: 10_000,
@@ -8,16 +44,26 @@ export const apiClient = axios.create({
apiClient.interceptors.request.use((config) => { apiClient.interceptors.request.use((config) => {
const lang = (typeof navigator !== 'undefined' && navigator.language) || 'ru-RU' const lang = (typeof navigator !== 'undefined' && navigator.language) || 'ru-RU'
config.headers['Accept-Language'] = `${lang},ru-RU;q=0.9,en-US;q=0.8` config.headers['Accept-Language'] = `${lang},ru-RU;q=0.9,en-US;q=0.8`
const token = localStorage.getItem('ordinis.token') if (accessToken) {
if (token) config.headers['Authorization'] = `Bearer ${token}` config.headers['Authorization'] = `Bearer ${accessToken}`
} else {
delete config.headers['Authorization']
}
return config return config
}) })
// Единственный 401 interceptor. Логика silent renew / redirect / loop guard
// делегируется handler'у который TokenSync устанавливает через
// setUnauthorizedHandler().
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(resp) => resp, (resp) => resp,
(error) => { (error) => {
if (error.response?.status === 401) { if (error?.response?.status === 401 && unauthorizedHandler) {
localStorage.removeItem('ordinis.token') try {
unauthorizedHandler(error)
} catch (e) {
console.error('[api] unauthorized handler failed', e)
}
} }
return Promise.reject(error) return Promise.reject(error)
}, },
+56 -5
View File
@@ -1,3 +1,4 @@
import { useCallback, useRef } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
apiClient, apiClient,
@@ -16,11 +17,49 @@ import {
type WebhookTestPingResult, type WebhookTestPingResult,
} from './client' } from './client'
const idempotencyKey = () => /**
* Generate UUID v4 (или fallback). Раньше вызывался **per mutate** в каждой
* useCreateRecord/useUpdateRecord — двойной submit формы (Enter + click) давал
* два разных ключа → server создавал два состояния вместо идемпотентного
* deduping. Теперь caller генерирует ключ один раз на форму через
* {@link useFormIdempotencyKey} и передаёт его mutation'у.
*/
export const generateIdempotencyKey = (): string =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID() ? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}` : `${Date.now()}-${Math.random().toString(36).slice(2)}`
/**
* Stable idempotency key для жизненного цикла одной формы.
*
* <p>Возвращает {@code [key, reset]}. {@code key} стабилен между ререндерами;
* {@code reset()} генерирует новый — вызывается на onSuccess чтобы следующая
* форма (например, edit того же record после save) получила свежий ключ.
*
* <p>Защита от двойного submit (Enter + click за одну сессию формы): оба
* запроса несут один и тот же {@code Idempotency-Key} → backend deduplicate'ит
* на втором.
*
* <p><b>Usage:</b>
* <pre>{@code
* const [idempotencyKey, resetIdempotencyKey] = useFormIdempotencyKey()
* const createMut = useCreateRecord(name)
* // submit:
* createMut.mutate(
* { payload: req, idempotencyKey },
* { onSuccess: () => { resetIdempotencyKey(); closeModal() } },
* )
* }</pre>
*/
export const useFormIdempotencyKey = (): [string, () => void] => {
const ref = useRef<string | null>(null)
if (ref.current === null) ref.current = generateIdempotencyKey()
const reset = useCallback(() => {
ref.current = generateIdempotencyKey()
}, [])
return [ref.current, reset]
}
export const useCreateDictionary = () => { export const useCreateDictionary = () => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -54,14 +93,22 @@ export const useUpdateDictionary = () => {
}) })
} }
/**
* Create-record mutation. {@code idempotencyKey} — required, передаётся caller'ом
* (один на жизнь формы; см. {@link useFormIdempotencyKey}). Это защищает от
* двойного submit (Enter + click): backend deduplicate'ит по тому же ключу.
*/
export const useCreateRecord = (dictionaryName: string) => { export const useCreateRecord = (dictionaryName: string) => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (req: CreateRecordRequest): Promise<RecordResponse> => { mutationFn: async (params: {
payload: CreateRecordRequest
idempotencyKey: string
}): Promise<RecordResponse> => {
const { data } = await apiClient.post<RecordResponse>( const { data } = await apiClient.post<RecordResponse>(
`/dictionaries/${dictionaryName}/records`, `/dictionaries/${dictionaryName}/records`,
req, params.payload,
{ headers: { 'Idempotency-Key': idempotencyKey() } }, { headers: { 'Idempotency-Key': params.idempotencyKey } },
) )
return data return data
}, },
@@ -71,17 +118,21 @@ export const useCreateRecord = (dictionaryName: string) => {
}) })
} }
/**
* Update-record mutation. См. {@link useCreateRecord} для idempotency rationale.
*/
export const useUpdateRecord = (dictionaryName: string) => { export const useUpdateRecord = (dictionaryName: string) => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (params: { mutationFn: async (params: {
businessKey: string businessKey: string
payload: CreateRecordRequest payload: CreateRecordRequest
idempotencyKey: string
}): Promise<RecordResponse> => { }): Promise<RecordResponse> => {
const { data } = await apiClient.put<RecordResponse>( const { data } = await apiClient.put<RecordResponse>(
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}`, `/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}`,
params.payload, params.payload,
{ headers: { 'Idempotency-Key': idempotencyKey() } }, { headers: { 'Idempotency-Key': params.idempotencyKey } },
) )
return data return data
}, },
+13 -3
View File
@@ -26,12 +26,22 @@ export function RequireAuth({ children }: { children: ReactNode }) {
if (oidcError) return if (oidcError) return
if (auth.isLoading || auth.activeNavigator) return if (auth.isLoading || auth.activeNavigator) return
if (auth.isAuthenticated) return if (auth.isAuthenticated) return
// Critical guard: auth.error → signinRedirect уже упал (Keycloak недоступен,
// OIDC discovery 404 etc). Без этого guard'а useEffect зациклится: каждый
// failed redirect ставит auth.error → re-render → guard не срабатывает →
// снова signinRedirect. С guard'ом UI показывает "Не удалось подключиться"
// блок ниже (использует auth.error.message).
if (auth.error) return
auth.signinRedirect().catch((err) => { auth.signinRedirect().catch((err) => {
// Не уйти на signinRedirect = Keycloak недоступен. Логируем, дальше
// покажем сообщение об ошибке (auth.error будет выставлен).
console.error('[auth] signinRedirect failed', err) console.error('[auth] signinRedirect failed', err)
}) })
}, [auth.isAuthenticated, auth.isLoading, auth.activeNavigator, oidcError]) }, [
auth.isAuthenticated,
auth.isLoading,
auth.activeNavigator,
auth.error,
oidcError,
])
if (oidcError) { if (oidcError) {
return ( return (
+52 -34
View File
@@ -1,55 +1,73 @@
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import { useAuth } from 'react-oidc-context' import { useAuth } from 'react-oidc-context'
import { apiClient } from '@/api/client' import { setAccessToken, setUnauthorizedHandler } from '@/api/client'
import { TOKEN_STORAGE_KEY } from './oidcConfig' import { LEGACY_TOKEN_STORAGE_KEY } from './oidcConfig'
/** /**
* Синхронизирует {@code access_token} из react-oidc-context в localStorage и * Синхронизирует {@code access_token} из react-oidc-context в API client
* axios default header. Existing API код ничего не знает про OIDC — просто * (in-memory holder) и подключает 401 handler на silent renew / redirect.
* читает токен через axios interceptor.
* *
* <p>Mount внутри {@code <AuthProvider>}, рендерит null. * <p>Mount внутри {@code <AuthProvider>}, рендерит null. Должен быть один
* экземпляр на app — handler ставится глобально.
*
* <p><b>Single source of truth для 401:</b> раньше был дубликат interceptor'а
* в {@code api/client.ts} (просто удалял token из localStorage) + ещё один
* здесь (signinSilent/Redirect). Оба срабатывали на одном response — race и
* потенциальный redirect loop. Теперь handler один, через
* {@link setUnauthorizedHandler}.
*
* <p><b>Loop guard:</b> {@code redirecting} ref предотвращает burst
* параллельных signinRedirect'ов при N concurrent 401 (типичная картина
* после истечения токена — все pending requests возвращают 401 одновременно).
*/ */
export function TokenSync() { export function TokenSync() {
const auth = useAuth() const auth = useAuth()
const redirecting = useRef(false)
// 1) Token sync — single update path к accessToken holder в client.ts.
useEffect(() => { useEffect(() => {
const token = auth.user?.access_token setAccessToken(auth.user?.access_token ?? null)
if (token) { // Cleanup legacy localStorage key (was used до fix #12 + #2/3/13 cascade).
localStorage.setItem(TOKEN_STORAGE_KEY, token) // Идемпотентный no-op если ключа нет.
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}` if (typeof window !== 'undefined') {
} else { window.localStorage.removeItem(LEGACY_TOKEN_STORAGE_KEY)
localStorage.removeItem(TOKEN_STORAGE_KEY)
delete apiClient.defaults.headers.common['Authorization']
} }
}, [auth.user?.access_token]) }, [auth.user?.access_token])
// 401 → автоматический редирект на Keycloak login. Покрывает два случая: // 2) 401 handler — registered once на app mount, делегирует silent renew /
// 1. anonymous пользователь открыл UI (нет токена вовсе) — сразу на логин // redirect на Keycloak. Guard'ы:
// вместо красного "AxiosError 401". // - auth.isLoading / activeNavigator: OIDC bootstrap или redirect уже в
// 2. authenticated юзер у которого истёк access_token и silent renew не // progress — не дублируем.
// успел — пробуем silent, если не вышло — full redirect. // - URL содержит ?error=: вернулись из Keycloak с проваломпусть UI
// Guard против infinite loop: не редиректим пока auth.isLoading (initial OIDC // покажет RequireAuth error screen, не зацикливаемся.
// bootstrap), пока activeNavigator (signin/signout уже идёт), и если url // - redirecting ref: при burst 401 (N concurrent requests) первый
// содержит ?error= (вернулись с провалом из Keycloak — даём UI показать ошибку). // handler инициирует redirect, остальные no-op до его завершения.
useEffect(() => { useEffect(() => {
const id = apiClient.interceptors.response.use( setUnauthorizedHandler(() => {
(r) => r, if (auth.isLoading || auth.activeNavigator) return
(err) => {
if (err.response?.status !== 401) return Promise.reject(err)
if (auth.isLoading || auth.activeNavigator) return Promise.reject(err)
if (typeof window !== 'undefined' && window.location.search.includes('error=')) { if (typeof window !== 'undefined' && window.location.search.includes('error=')) {
return Promise.reject(err) return
}
if (redirecting.current) return
redirecting.current = true
const reset = () => {
redirecting.current = false
} }
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
auth.signinSilent().catch(() => auth.signinRedirect()) auth
.signinSilent()
.then(reset)
.catch(() => {
// Silent renew не удался — full redirect. reset не нужен:
// page reload сбросит state. Но catch всё равно нужен на случай
// если redirect promise отвергается (Keycloak unreachable).
auth.signinRedirect().catch(reset)
})
} else { } else {
auth.signinRedirect() auth.signinRedirect().catch(reset)
} }
return Promise.reject(err) })
}, return () => setUnauthorizedHandler(null)
)
return () => apiClient.interceptors.response.eject(id)
}, [auth]) }, [auth])
return null return null
+17 -2
View File
@@ -17,9 +17,17 @@ const baseSettings: UserManagerSettings = {
response_type: 'code', response_type: 'code',
automaticSilentRenew: true, automaticSilentRenew: true,
loadUserInfo: false, loadUserInfo: false,
// sessionStorage вместо localStorage: уменьшает XSS persistent reach на
// access_token + id_token. Trade-off: токен не переживает закрытие вкладки —
// на повторном открытии будет SSO redirect к Keycloak (но без user input
// если SSO session в Keycloak ещё жива, т.е. UX impact минимальный).
// Долгосрочное правильное решение — BFF + httpOnly cookie с refresh token,
// но это отдельный epic (server-side OIDC handler).
// См. также {@code apiClient}: access_token держится в in-memory holder'е,
// не читается из storage на каждый request.
userStore: userStore:
typeof window !== 'undefined' typeof window !== 'undefined'
? new WebStorageStateStore({ store: window.localStorage }) ? new WebStorageStateStore({ store: window.sessionStorage })
: undefined, : undefined,
} }
@@ -38,4 +46,11 @@ export const oidcAuthConfig: AuthProviderProps = {
}, },
} }
export const TOKEN_STORAGE_KEY = 'ordinis.token' // TOKEN_STORAGE_KEY (legacy 'ordinis.token' в localStorage) удалён вместе с
// fix дублирующих 401-interceptor'ов. Access token больше не дублируется
// в localStorage из приложения — он живёт в memory holder'е (см. apiClient
// setAccessToken) и oidc-client-ts userStore (sessionStorage, fix #12).
//
// Cleanup: на первый mount old клиенты могут иметь старый ключ — TokenSync
// его удаляет.
export const LEGACY_TOKEN_STORAGE_KEY = 'ordinis.token'
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useForm, Controller, type SubmitHandler } from 'react-hook-form' import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Ajv, { type ErrorObject } from 'ajv' import Ajv, { type ErrorObject } from 'ajv'
import addFormats from 'ajv-formats' import addFormats from 'ajv-formats'
@@ -652,19 +652,46 @@ const applyAjvErrors = (
setError: ReturnType<typeof useForm<FormValues>>['setError'], setError: ReturnType<typeof useForm<FormValues>>['setError'],
) => { ) => {
for (const err of errors) { for (const err of errors) {
const path = err.instancePath.replace(/^\//, '').replace(/\//g, '.') // AJV instancePath: "/foo/bar" → RHF path: "data.foo.bar".
const fieldPath = path ? (`data.${path}` as `data.${string}`) : 'data' // Раньше было "data" (root) для всех ошибок из-за {@code as 'data'} cast'а
setError(fieldPath as 'data', { type: 'ajv', message: err.message ?? 'invalid' }) // RHF складывал все ошибки в один ключ {@code formState.errors.data},
// tab badge со счётчиком стабильно показывал 0/1, поле не подсвечивалось.
const instancePath = err.instancePath.replace(/^\//, '').replace(/\//g, '.')
// {@code required} keyword: instancePath пустой, имя missing field в
// params.missingProperty (root уровень) или params.missingProperty
// относительно instancePath (nested objects).
let path = instancePath
if (
err.keyword === 'required' &&
err.params &&
typeof err.params === 'object' &&
'missingProperty' in err.params
) {
const missing = String((err.params as { missingProperty: string }).missingProperty)
path = path ? `${path}.${missing}` : missing
}
const fieldPath = (path ? `data.${path}` : 'data') as Path<FormValues>
setError(fieldPath, { type: 'ajv', message: err.message ?? 'invalid' })
} }
} }
const firstFieldFromErrors = (errors: ErrorObject[]): string | null => { const firstFieldFromErrors = (errors: ErrorObject[]): string | null => {
for (const err of errors) { for (const err of errors) {
const path = err.instancePath.replace(/^\//, '') const path = err.instancePath.replace(/^\//, '')
if (path) return path.split('/')[0] // required keyword: instancePath relative to parent object, missing
if (err.params && typeof err.params === 'object' && 'missingProperty' in err.params) { // property name в params.missingProperty. Combine, take top-level segment
return String((err.params as { missingProperty: string }).missingProperty) // — это то, что нужно для tab routing.
if (
err.keyword === 'required' &&
err.params &&
typeof err.params === 'object' &&
'missingProperty' in err.params
) {
const missing = String((err.params as { missingProperty: string }).missingProperty)
const full = path ? `${path}/${missing}` : missing
return full.split('/')[0]
} }
if (path) return path.split('/')[0]
} }
return null return null
} }
+32
View File
@@ -65,3 +65,35 @@ export const nowIsoLocal = (): string => {
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
return `${formatIsoDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:00${localTzOffset(d)}` return `${formatIsoDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:00${localTzOffset(d)}`
} }
/**
* Форматирует ISO datetime в формат для `<input type="datetime-local">`:
* "YYYY-MM-DDTHH:mm" в local TZ (без offset).
*
* <p>Use case: time-travel picker. Раньше использовался
* {@code new Date(s).toISOString().slice(0,16)} что отдаёт UTC — у пользователя в
* +03:00 picker показывал значение на 3 часа раньше выбранного, а на onChange
* value трактовался как local → time drift на каждое редактирование.
*/
export const toDateTimeLocalInput = (s: string | undefined): string => {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/**
* Парсит value из `<input type="datetime-local">` ("YYYY-MM-DDTHH:mm") в local-TZ
* ISO datetime со смещением: "2026-05-08T14:30:00+03:00".
*
* <p>Pair с {@link toDateTimeLocalInput} — round-trip preserves wall clock time.
*/
export const fromDateTimeLocalInput = (v: string): string => {
if (!v) return ''
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(v)
if (!m) return ''
const [, y, mo, day, hh, mm] = m
const d = new Date(Number(y), Number(mo) - 1, Number(day), Number(hh), Number(mm), 0)
return `${formatIsoDate(d)}T${hh}:${mm}:00${localTzOffset(d)}`
}
@@ -30,7 +30,7 @@ import {
useRecords, useRecords,
type RecordsFilter, type RecordsFilter,
} from '@/api/queries' } from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations' import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client' import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
@@ -38,7 +38,7 @@ import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDepend
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog' import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { nowIsoLocal } from '@/lib/dates' import { nowIsoLocal, toDateTimeLocalInput, fromDateTimeLocalInput } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales' import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
@@ -138,6 +138,10 @@ function DictionaryDetail() {
}, [pendingDraftsQuery.data]) }, [pendingDraftsQuery.data])
const createMut = useCreateRecord(name) const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name) const updateMut = useUpdateRecord(name)
// Per-form idempotency key. Stable между ререндерами для одной сессии edit
// modal'а: двойной submit (Enter + click) не создаст 2 record'а — backend
// deduplicate'ит по тому же ключу. Reset на onSuccess.
const [recordIdempotencyKey, resetRecordIdempotencyKey] = useFormIdempotencyKey()
const closeMut = useCloseRecord(name) const closeMut = useCloseRecord(name)
const bulkCloseMut = useBulkCloseRecords(name) const bulkCloseMut = useBulkCloseRecords(name)
const bulkExportMut = useBulkExportRecords(name) const bulkExportMut = useBulkExportRecords(name)
@@ -364,13 +368,30 @@ function DictionaryDetail() {
const handleSubmit = (req: CreateRecordRequest) => { const handleSubmit = (req: CreateRecordRequest) => {
if (edit.kind === 'create') { if (edit.kind === 'create') {
createMut.mutate(req, { onSuccess: () => setEdit({ kind: 'closed' }) }) createMut.mutate(
{ payload: req, idempotencyKey: recordIdempotencyKey },
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
},
)
return return
} }
if (edit.kind === 'edit') { if (edit.kind === 'edit') {
updateMut.mutate( updateMut.mutate(
{ businessKey: edit.record.businessKey, payload: req }, {
{ onSuccess: () => setEdit({ kind: 'closed' }) }, businessKey: edit.record.businessKey,
payload: req,
idempotencyKey: recordIdempotencyKey,
},
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
},
) )
} }
} }
@@ -519,18 +540,18 @@ function DictionaryDetail() {
<input <input
type="datetime-local" type="datetime-local"
className="px-2 py-1 rounded-sm border border-regolith bg-white text-2xs font-mono" className="px-2 py-1 rounded-sm border border-regolith bg-white text-2xs font-mono"
value={ // datetime-local требует local time без offset. toISOString() отдаёт
timeTravelAt // UTC → у пользователя в +03:00 picker показывал значение на 3ч
? // datetime-local не принимает Z/offset — конвертируем в local. // раньше выбранного, и на каждом редактировании round-trip drift'ил
new Date(timeTravelAt).toISOString().slice(0, 16) // время. toDateTimeLocalInput / fromDateTimeLocalInput сохраняют
: '' // wall-clock время через формат с явным local-TZ offset.
} value={toDateTimeLocalInput(timeTravelAt)}
onChange={(e) => { onChange={(e) => {
if (!e.target.value) { if (!e.target.value) {
setTimeTravelAt(undefined) setTimeTravelAt(undefined)
return return
} }
setTimeTravelAt(new Date(e.target.value).toISOString()) setTimeTravelAt(fromDateTimeLocalInput(e.target.value))
}} }}
aria-label={t('timeTravel.label')} aria-label={t('timeTravel.label')}
/> />