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

This commit is contained in:
Александр Зимин
2026-05-10 16:22:35 +00:00
parent f2614244bf
commit fc2fb35717
8 changed files with 292 additions and 72 deletions
+50 -4
View File
@@ -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)
},