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:
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (typeof window !== 'undefined' && window.location.search.includes('error=')) {
|
||||||
if (err.response?.status !== 401) return Promise.reject(err)
|
return
|
||||||
if (auth.isLoading || auth.activeNavigator) return Promise.reject(err)
|
}
|
||||||
if (typeof window !== 'undefined' && window.location.search.includes('error=')) {
|
if (redirecting.current) return
|
||||||
return Promise.reject(err)
|
redirecting.current = true
|
||||||
}
|
const reset = () => {
|
||||||
if (auth.isAuthenticated) {
|
redirecting.current = false
|
||||||
auth.signinSilent().catch(() => auth.signinRedirect())
|
}
|
||||||
} else {
|
if (auth.isAuthenticated) {
|
||||||
auth.signinRedirect()
|
auth
|
||||||
}
|
.signinSilent()
|
||||||
return Promise.reject(err)
|
.then(reset)
|
||||||
},
|
.catch(() => {
|
||||||
)
|
// Silent renew не удался — full redirect. reset не нужен:
|
||||||
return () => apiClient.interceptors.response.eject(id)
|
// page reload сбросит state. Но catch всё равно нужен на случай
|
||||||
|
// если redirect promise отвергается (Keycloak unreachable).
|
||||||
|
auth.signinRedirect().catch(reset)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
auth.signinRedirect().catch(reset)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => setUnauthorizedHandler(null)
|
||||||
}, [auth])
|
}, [auth])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user