diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 73008ee..25f0773 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -1,5 +1,41 @@ import axios from 'axios' +/** + * Source of truth для access token. {@link TokenSync} обновляет это значение + * синхронно при изменении OIDC user state. Interceptor читает из in-memory + * holder'а — никаких {@code defaults.headers}, никаких {@code localStorage} poll'ов. + * + *

Зачем in-memory holder, не localStorage: + *

+ */ +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({ baseURL: import.meta.env.VITE_API_BASE ?? '/api/v1', timeout: 10_000, @@ -8,16 +44,26 @@ export const apiClient = axios.create({ apiClient.interceptors.request.use((config) => { const lang = (typeof navigator !== 'undefined' && navigator.language) || 'ru-RU' config.headers['Accept-Language'] = `${lang},ru-RU;q=0.9,en-US;q=0.8` - const token = localStorage.getItem('ordinis.token') - if (token) config.headers['Authorization'] = `Bearer ${token}` + if (accessToken) { + config.headers['Authorization'] = `Bearer ${accessToken}` + } else { + delete config.headers['Authorization'] + } return config }) +// Единственный 401 interceptor. Логика silent renew / redirect / loop guard +// делегируется handler'у который TokenSync устанавливает через +// setUnauthorizedHandler(). apiClient.interceptors.response.use( (resp) => resp, (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('ordinis.token') + if (error?.response?.status === 401 && unauthorizedHandler) { + try { + unauthorizedHandler(error) + } catch (e) { + console.error('[api] unauthorized handler failed', e) + } } return Promise.reject(error) }, diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index b95757e..c22694f 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -1,3 +1,4 @@ +import { useCallback, useRef } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient, @@ -16,11 +17,49 @@ import { type WebhookTestPingResult, } 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 ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}` +/** + * Stable idempotency key для жизненного цикла одной формы. + * + *

Возвращает {@code [key, reset]}. {@code key} стабилен между ререндерами; + * {@code reset()} генерирует новый — вызывается на onSuccess чтобы следующая + * форма (например, edit того же record после save) получила свежий ключ. + * + *

Защита от двойного submit (Enter + click за одну сессию формы): оба + * запроса несут один и тот же {@code Idempotency-Key} → backend deduplicate'ит + * на втором. + * + *

Usage: + *

{@code
+ * const [idempotencyKey, resetIdempotencyKey] = useFormIdempotencyKey()
+ * const createMut = useCreateRecord(name)
+ * // submit:
+ * createMut.mutate(
+ *   { payload: req, idempotencyKey },
+ *   { onSuccess: () => { resetIdempotencyKey(); closeModal() } },
+ * )
+ * }
+ */ +export const useFormIdempotencyKey = (): [string, () => void] => { + const ref = useRef(null) + if (ref.current === null) ref.current = generateIdempotencyKey() + const reset = useCallback(() => { + ref.current = generateIdempotencyKey() + }, []) + return [ref.current, reset] +} + export const useCreateDictionary = () => { const qc = useQueryClient() 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) => { const qc = useQueryClient() return useMutation({ - mutationFn: async (req: CreateRecordRequest): Promise => { + mutationFn: async (params: { + payload: CreateRecordRequest + idempotencyKey: string + }): Promise => { const { data } = await apiClient.post( `/dictionaries/${dictionaryName}/records`, - req, - { headers: { 'Idempotency-Key': idempotencyKey() } }, + params.payload, + { headers: { 'Idempotency-Key': params.idempotencyKey } }, ) return data }, @@ -71,17 +118,21 @@ export const useCreateRecord = (dictionaryName: string) => { }) } +/** + * Update-record mutation. См. {@link useCreateRecord} для idempotency rationale. + */ export const useUpdateRecord = (dictionaryName: string) => { const qc = useQueryClient() return useMutation({ mutationFn: async (params: { businessKey: string payload: CreateRecordRequest + idempotencyKey: string }): Promise => { const { data } = await apiClient.put( `/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}`, params.payload, - { headers: { 'Idempotency-Key': idempotencyKey() } }, + { headers: { 'Idempotency-Key': params.idempotencyKey } }, ) return data }, diff --git a/ordinis-admin-ui/src/auth/RequireAuth.tsx b/ordinis-admin-ui/src/auth/RequireAuth.tsx index e7a9e37..779ebf5 100644 --- a/ordinis-admin-ui/src/auth/RequireAuth.tsx +++ b/ordinis-admin-ui/src/auth/RequireAuth.tsx @@ -26,12 +26,22 @@ export function RequireAuth({ children }: { children: ReactNode }) { if (oidcError) return if (auth.isLoading || auth.activeNavigator) 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) => { - // Не уйти на signinRedirect = Keycloak недоступен. Логируем, дальше - // покажем сообщение об ошибке (auth.error будет выставлен). console.error('[auth] signinRedirect failed', err) }) - }, [auth.isAuthenticated, auth.isLoading, auth.activeNavigator, oidcError]) + }, [ + auth.isAuthenticated, + auth.isLoading, + auth.activeNavigator, + auth.error, + oidcError, + ]) if (oidcError) { return ( diff --git a/ordinis-admin-ui/src/auth/TokenSync.tsx b/ordinis-admin-ui/src/auth/TokenSync.tsx index e6f820d..b577df4 100644 --- a/ordinis-admin-ui/src/auth/TokenSync.tsx +++ b/ordinis-admin-ui/src/auth/TokenSync.tsx @@ -1,55 +1,73 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useAuth } from 'react-oidc-context' -import { apiClient } from '@/api/client' -import { TOKEN_STORAGE_KEY } from './oidcConfig' +import { setAccessToken, setUnauthorizedHandler } from '@/api/client' +import { LEGACY_TOKEN_STORAGE_KEY } from './oidcConfig' /** - * Синхронизирует {@code access_token} из react-oidc-context в localStorage и - * axios default header. Existing API код ничего не знает про OIDC — просто - * читает токен через axios interceptor. + * Синхронизирует {@code access_token} из react-oidc-context в API client + * (in-memory holder) и подключает 401 handler на silent renew / redirect. * - *

Mount внутри {@code }, рендерит null. + *

Mount внутри {@code }, рендерит null. Должен быть один + * экземпляр на app — handler ставится глобально. + * + *

Single source of truth для 401: раньше был дубликат interceptor'а + * в {@code api/client.ts} (просто удалял token из localStorage) + ещё один + * здесь (signinSilent/Redirect). Оба срабатывали на одном response — race и + * потенциальный redirect loop. Теперь handler один, через + * {@link setUnauthorizedHandler}. + * + *

Loop guard: {@code redirecting} ref предотвращает burst + * параллельных signinRedirect'ов при N concurrent 401 (типичная картина + * после истечения токена — все pending requests возвращают 401 одновременно). */ export function TokenSync() { const auth = useAuth() + const redirecting = useRef(false) + // 1) Token sync — single update path к accessToken holder в client.ts. useEffect(() => { - const token = auth.user?.access_token - if (token) { - localStorage.setItem(TOKEN_STORAGE_KEY, token) - apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}` - } else { - localStorage.removeItem(TOKEN_STORAGE_KEY) - delete apiClient.defaults.headers.common['Authorization'] + setAccessToken(auth.user?.access_token ?? null) + // Cleanup legacy localStorage key (was used до fix #12 + #2/3/13 cascade). + // Идемпотентный no-op если ключа нет. + if (typeof window !== 'undefined') { + window.localStorage.removeItem(LEGACY_TOKEN_STORAGE_KEY) } }, [auth.user?.access_token]) - // 401 → автоматический редирект на Keycloak login. Покрывает два случая: - // 1. anonymous пользователь открыл UI (нет токена вовсе) — сразу на логин - // вместо красного "AxiosError 401". - // 2. authenticated юзер у которого истёк access_token и silent renew не - // успел — пробуем silent, если не вышло — full redirect. - // Guard против infinite loop: не редиректим пока auth.isLoading (initial OIDC - // bootstrap), пока activeNavigator (signin/signout уже идёт), и если url - // содержит ?error= (вернулись с провалом из Keycloak — даём UI показать ошибку). + // 2) 401 handler — registered once на app mount, делегирует silent renew / + // redirect на Keycloak. Guard'ы: + // - auth.isLoading / activeNavigator: OIDC bootstrap или redirect уже в + // progress — не дублируем. + // - URL содержит ?error=: вернулись из Keycloak с провалом — пусть UI + // покажет RequireAuth error screen, не зацикливаемся. + // - redirecting ref: при burst 401 (N concurrent requests) первый + // handler инициирует redirect, остальные no-op до его завершения. useEffect(() => { - const id = apiClient.interceptors.response.use( - (r) => r, - (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=')) { - return Promise.reject(err) - } - if (auth.isAuthenticated) { - auth.signinSilent().catch(() => auth.signinRedirect()) - } else { - auth.signinRedirect() - } - return Promise.reject(err) - }, - ) - return () => apiClient.interceptors.response.eject(id) + setUnauthorizedHandler(() => { + if (auth.isLoading || auth.activeNavigator) return + if (typeof window !== 'undefined' && window.location.search.includes('error=')) { + return + } + if (redirecting.current) return + redirecting.current = true + const reset = () => { + redirecting.current = false + } + if (auth.isAuthenticated) { + auth + .signinSilent() + .then(reset) + .catch(() => { + // Silent renew не удался — full redirect. reset не нужен: + // page reload сбросит state. Но catch всё равно нужен на случай + // если redirect promise отвергается (Keycloak unreachable). + auth.signinRedirect().catch(reset) + }) + } else { + auth.signinRedirect().catch(reset) + } + }) + return () => setUnauthorizedHandler(null) }, [auth]) return null diff --git a/ordinis-admin-ui/src/auth/oidcConfig.ts b/ordinis-admin-ui/src/auth/oidcConfig.ts index fbc4a3f..12cee86 100644 --- a/ordinis-admin-ui/src/auth/oidcConfig.ts +++ b/ordinis-admin-ui/src/auth/oidcConfig.ts @@ -17,9 +17,17 @@ const baseSettings: UserManagerSettings = { response_type: 'code', automaticSilentRenew: true, 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: typeof window !== 'undefined' - ? new WebStorageStateStore({ store: window.localStorage }) + ? new WebStorageStateStore({ store: window.sessionStorage }) : 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' diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index e60efa7..cebc62d 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -1,5 +1,5 @@ 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 Ajv, { type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' @@ -652,19 +652,46 @@ const applyAjvErrors = ( setError: ReturnType>['setError'], ) => { for (const err of errors) { - const path = err.instancePath.replace(/^\//, '').replace(/\//g, '.') - const fieldPath = path ? (`data.${path}` as `data.${string}`) : 'data' - setError(fieldPath as 'data', { type: 'ajv', message: err.message ?? 'invalid' }) + // AJV instancePath: "/foo/bar" → RHF path: "data.foo.bar". + // Раньше было "data" (root) для всех ошибок из-за {@code as 'data'} cast'а — + // 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 + setError(fieldPath, { type: 'ajv', message: err.message ?? 'invalid' }) } } const firstFieldFromErrors = (errors: ErrorObject[]): string | null => { for (const err of errors) { const path = err.instancePath.replace(/^\//, '') - if (path) return path.split('/')[0] - if (err.params && typeof err.params === 'object' && 'missingProperty' in err.params) { - return String((err.params as { missingProperty: string }).missingProperty) + // required keyword: instancePath relative to parent object, missing + // property name в params.missingProperty. Combine, take top-level segment + // — это то, что нужно для 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 } diff --git a/ordinis-admin-ui/src/lib/dates.ts b/ordinis-admin-ui/src/lib/dates.ts index cd54d5b..26ce47f 100644 --- a/ordinis-admin-ui/src/lib/dates.ts +++ b/ordinis-admin-ui/src/lib/dates.ts @@ -65,3 +65,35 @@ export const nowIsoLocal = (): string => { const pad = (n: number) => String(n).padStart(2, '0') return `${formatIsoDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:00${localTzOffset(d)}` } + +/** + * Форматирует ISO datetime в формат для ``: + * "YYYY-MM-DDTHH:mm" в local TZ (без offset). + * + *

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 из `` ("YYYY-MM-DDTHH:mm") в local-TZ + * ISO datetime со смещением: "2026-05-08T14:30:00+03:00". + * + *

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)}` +} diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index e05dc34..289e77c 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -30,7 +30,7 @@ import { useRecords, type RecordsFilter, } 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 { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' @@ -38,7 +38,7 @@ import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDepend import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' 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 { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' @@ -138,6 +138,10 @@ function DictionaryDetail() { }, [pendingDraftsQuery.data]) const createMut = useCreateRecord(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 bulkCloseMut = useBulkCloseRecords(name) const bulkExportMut = useBulkExportRecords(name) @@ -364,13 +368,30 @@ function DictionaryDetail() { const handleSubmit = (req: CreateRecordRequest) => { if (edit.kind === 'create') { - createMut.mutate(req, { onSuccess: () => setEdit({ kind: 'closed' }) }) + createMut.mutate( + { payload: req, idempotencyKey: recordIdempotencyKey }, + { + onSuccess: () => { + setEdit({ kind: 'closed' }) + resetRecordIdempotencyKey() + }, + }, + ) return } if (edit.kind === 'edit') { 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() { { if (!e.target.value) { setTimeTravelAt(undefined) return } - setTimeTravelAt(new Date(e.target.value).toISOString()) + setTimeTravelAt(fromDateTimeLocalInput(e.target.value)) }} aria-label={t('timeTravel.label')} />