fix(admin-ui): top-5 critical review bugs (auth + AJV + TZ + idempotency + storage)
This commit is contained in:
@@ -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'ов.
|
||||
*
|
||||
* <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({
|
||||
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)
|
||||
},
|
||||
|
||||
@@ -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 для жизненного цикла одной формы.
|
||||
*
|
||||
* <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 = () => {
|
||||
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<RecordResponse> => {
|
||||
mutationFn: async (params: {
|
||||
payload: CreateRecordRequest
|
||||
idempotencyKey: string
|
||||
}): Promise<RecordResponse> => {
|
||||
const { data } = await apiClient.post<RecordResponse>(
|
||||
`/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<RecordResponse> => {
|
||||
const { data } = await apiClient.put<RecordResponse>(
|
||||
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}`,
|
||||
params.payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey() } },
|
||||
{ headers: { 'Idempotency-Key': params.idempotencyKey } },
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user