fix(admin-ui): v1.1.1 patch — version routing + guest mode + timeline UX
This commit is contained in:
@@ -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}</>
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export function TokenSync() {
|
||||
redirecting.current = false
|
||||
}
|
||||
if (auth.isAuthenticated) {
|
||||
// Authenticated user hit 401 → token истёк. Silent renew, иначе full
|
||||
// redirect для re-login.
|
||||
auth
|
||||
.signinSilent()
|
||||
.then(reset)
|
||||
@@ -64,7 +66,13 @@ export function TokenSync() {
|
||||
auth.signinRedirect().catch(reset)
|
||||
})
|
||||
} else {
|
||||
auth.signinRedirect().catch(reset)
|
||||
// Anonymous user попытался hit auth-required endpoint (mutation,
|
||||
// INTERNAL/RESTRICTED scope dictionary). Не редиректим автоматически —
|
||||
// UI должен сам показать «Войдите для редактирования» CTA, а юзер
|
||||
// сознательно кликнет «Войти». Авто-redirect ломает guest mode:
|
||||
// случайный 401 от любого API (например INTERNAL fetch) выкинул бы
|
||||
// в Keycloak без явного намерения.
|
||||
reset()
|
||||
}
|
||||
})
|
||||
return () => setUnauthorizedHandler(null)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useAuth } from 'react-oidc-context'
|
||||
|
||||
/**
|
||||
* Может ли текущий пользователь выполнять mutation операции (create / update /
|
||||
* delete / close / cascade).
|
||||
*
|
||||
* <p>Гость (anonymous) видит UI в read-only режиме: кнопки «Создать», «Удалить»,
|
||||
* иконки edit'а скрыты, schema editor disabled. Backend всё равно отрежет 401
|
||||
* на mutation endpoint'ах (writer требует JWT для POST/PUT/PATCH/DELETE), но
|
||||
* скрытие UI избегает confusing user experience «жмёшь кнопку → toast 401».
|
||||
*
|
||||
* <p>Использовать в компонентах вместо прямого {@code useAuth().isAuthenticated},
|
||||
* чтобы при будущей более тонкой role-based gate'е (например, разрешить
|
||||
* INTERNAL юзерам только PUBLIC mutations) — поменять одно место.
|
||||
*/
|
||||
export function useCanMutate(): boolean {
|
||||
const auth = useAuth()
|
||||
return auth.isAuthenticated
|
||||
}
|
||||
Reference in New Issue
Block a user