fix(admin-ui): v1.1.1 patch — version routing + guest mode + timeline UX

This commit is contained in:
Александр Зимин
2026-05-10 19:34:47 +00:00
parent 54817e1a9b
commit 69f1a05a1f
8 changed files with 419 additions and 144 deletions
+36 -40
View File
@@ -1,19 +1,27 @@
import { useEffect, type ReactNode } from 'react'
import { type ReactNode } from 'react'
import { useAuth } from 'react-oidc-context'
/**
* Гейтит всё содержимое приложения по аутентификации. Если юзер не залогинен —
* сразу редиректим на Keycloak (без промежуточной страницы "Вы не авторизованы"
* и без красного 401 от axios). Пока auth state ещё определяется — показываем
* нейтральный spinner.
* Auth gate с гостевым режимом view-only.
*
* Сценарии:
* - First visit, нет токена → signinRedirect → Keycloak login → redirect_uri →
* onSigninCallback чистит URL → дети рендерятся.
* - Истёк access_token, silent renew не успел → 401 от backend ловится в
* {@link TokenSync} interceptor, который дёргает signinSilent/signinRedirect.
* - Возврат с ошибкой OIDC (?error=access_denied) → не редиректим в цикле,
* показываем сообщение.
* <p>До v1.1.1 этот компонент force-redirect'ил неаутентифицированных юзеров
* на Keycloak. Теперь — guest-friendly: anonymous user видит UI с PUBLIC scope
* данными (backend отдаёт PUBLIC dictionaries без auth, см. SecurityConfig
* permissive mode + ScopeContext anonymous → PUBLIC).
*
* <p>Логин теперь пользовательское действие: клик на кнопку «Войти» в top bar
* → {@link useAuth().signinRedirect()}. После возврата из Keycloak пользователь
* получает scope по ролям JWT и видит INTERNAL/RESTRICTED данные.
*
* <p>Сценарии:
* <ul>
* <li>First visit, нет токена → render children, top bar показывает «Войти».</li>
* <li>Click «Войти» → signinRedirect → Keycloak → callback → authenticated.</li>
* <li>OIDC error в URL (?error=...) → error screen (не редиректим в цикле).</li>
* <li>auth.error (Keycloak недоступен) → error screen с сообщением.</li>
* <li>Mutations пытаются 401 → TokenSync ловит, делает silentRenew или
* показывает inline ошибку (anonymous user → не редиректим без CTA).</li>
* </ul>
*/
export function RequireAuth({ children }: { children: ReactNode }) {
const auth = useAuth()
@@ -22,27 +30,6 @@ export function RequireAuth({ children }: { children: ReactNode }) {
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).get('error')
useEffect(() => {
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) => {
console.error('[auth] signinRedirect failed', err)
})
}, [
auth.isAuthenticated,
auth.isLoading,
auth.activeNavigator,
auth.error,
oidcError,
])
if (oidcError) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
@@ -66,24 +53,33 @@ export function RequireAuth({ children }: { children: ReactNode }) {
)
}
// Keycloak unreachable / OIDC discovery failed — показываем ошибку, но НЕ
// блокируем UI (guest mode всё равно работает на read-api без JWT). Юзер
// может видеть PUBLIC данные пока Keycloak вернётся в строй.
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 className="px-4 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-900 text-center">
Авторизация недоступна: {auth.error.message}. Доступен просмотр публичных данных.
</div>
</div>
{children}
</>
)
}
if (!auth.isAuthenticated) {
// Loading — initial OIDC bootstrap (silent SSO check). Показываем нейтральный
// spinner, чтобы избежать flash of unauthenticated UI если silent SSO успеет
// вернуть active session.
if (auth.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-sm text-carbon/60 font-secondary">Перенаправление на вход</div>
<div className="text-sm text-carbon/60 font-secondary">Загрузка</div>
</div>
)
}
// Anonymous OR authenticated — рендерим children всегда. UI в дочерних
// компонентах сам адаптируется (через useAuth().isAuthenticated):
// hide mutation buttons, show «Войти» CTA, тонировать blocked actions.
return <>{children}</>
}