feat(admin-ui): RequireAuth gate + RTL component тесты

RequireAuth: гейтит весь UI по аутентификации. Anonymous юзер вместо
красного "AxiosError 401" получает immediate signinRedirect на Keycloak;
authenticated юзер с истёкшим токеном получает silent renew → fallback на
full redirect. Guards против infinite loop:
- auth.isLoading / activeNavigator — bootstrap или signin/signout уже идёт
- ?error= в URL — вернулись с провалом из Keycloak, показываем сообщение
- auth.error без isAuthenticated — Keycloak недоступен, показываем сообщение

main.tsx: RequireAuth обёрнут вокруг Router/Query, чтобы tan-stack-router и
React Query не делали запросов до auth (иначе 401-каскад на старте).

TokenSync: 401 interceptor теперь редиректит и anonymous (раньше срабатывал
только для authenticated с истёкшим токеном), с теми же guards.

RTL component тесты — 22 теста на 3 ключевых компонента:
- SchemaBuilder (7): empty state, add/remove/reorder properties
- PropertyEditor (10): kind change, required/unique toggle, kind-specific
  поля (string→pattern, integer→min/max, enum→values, array_string→uniqueItems),
  move up/down disabled на boundaries
- SchemaDrivenForm (5): identity tab с required, переключение на extra,
  isPending → save disabled, cancel callback, serverError → Alert

Setup:
- vite.config.ts: test block (jsdom env, setup file, css off)
- src/test/setup.ts: jest-dom matchers, cleanup, HTMLCanvasElement.getContext
  stub (lottie-web из @nstart/ui крашит в jsdom без canvas)
- src/test/render.tsx: renderWithI18n helper, форсит ru-RU
  (LanguageDetector в node по умолчанию даёт en-US)

Deps: +@testing-library/react +@testing-library/jest-dom
+@testing-library/user-event +jsdom
This commit is contained in:
Zimin A.N.
2026-05-06 00:11:07 +03:00
parent 01cca3a6f4
commit ab6f6cb860
11 changed files with 1098 additions and 11 deletions
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, type ReactNode } from 'react'
import { useAuth } from 'react-oidc-context'
/**
* Гейтит всё содержимое приложения по аутентификации. Если юзер не залогинен —
* сразу редиректим на Keycloak (без промежуточной страницы "Вы не авторизованы"
* и без красного 401 от axios). Пока auth state ещё определяется — показываем
* нейтральный spinner.
*
* Сценарии:
* - First visit, нет токена → signinRedirect → Keycloak login → redirect_uri →
* onSigninCallback чистит URL → дети рендерятся.
* - Истёк access_token, silent renew не успел → 401 от backend ловится в
* {@link TokenSync} interceptor, который дёргает signinSilent/signinRedirect.
* - Возврат с ошибкой OIDC (?error=access_denied) → не редиректим в цикле,
* показываем сообщение.
*/
export function RequireAuth({ children }: { children: ReactNode }) {
const auth = useAuth()
const oidcError =
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).get('error')
useEffect(() => {
if (oidcError) return
if (auth.isLoading || auth.activeNavigator) return
if (auth.isAuthenticated) return
auth.signinRedirect().catch((err) => {
// Не уйти на signinRedirect = Keycloak недоступен. Логируем, дальше
// покажем сообщение об ошибке (auth.error будет выставлен).
console.error('[auth] signinRedirect failed', err)
})
}, [auth.isAuthenticated, auth.isLoading, auth.activeNavigator, oidcError])
if (oidcError) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="max-w-md text-center space-y-2">
<h1 className="text-lg font-primary">Ошибка входа</h1>
<p className="text-sm text-carbon/70">
Keycloak вернул ошибку:{' '}
<code className="font-mono">{oidcError}</code>
</p>
<button
type="button"
className="mt-4 inline-flex items-center justify-center rounded-sm border border-ultramarain text-ultramarain px-4 py-2 text-sm hover:bg-orbit"
onClick={() => {
window.location.href = '/'
}}
>
Попробовать снова
</button>
</div>
</div>
)
}
if (auth.error && !auth.isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="max-w-md text-center space-y-2">
<h1 className="text-lg font-primary">Не удалось подключиться к авторизации</h1>
<p className="text-sm text-carbon/70">{auth.error.message}</p>
</div>
</div>
)
}
if (!auth.isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-sm text-carbon/60 font-secondary">Перенаправление на вход</div>
</div>
)
}
return <>{children}</>
}