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: + *
Возвращает {@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 = useRefMount внутри {@code Mount внутри {@code 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 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')}
/>