Files
mdm-ordinis/ordinis-admin-ui/src/auth/RequireAuth.tsx
T
Zimin A.N. 7e435e07b7 feat(auth): миграция oidc-client-ts → keycloak-js (altum-pattern)
Phase 1a из плана: только swap библиотеки, realm/URL остаются на
auth.nstart.space/realms/nstart. Phase 2 (intgr с altum auth-service)
— отдельный эпик.

Зачем. oidc-client-ts с automaticSilentRenew триггерил бесконечный
POST /token loop когда refresh_token истекал (см. MR !269 revert,
~30+ 400'к в console, страница залипала на 403). Altum давно ушёл
с oidc-client-ts на keycloak-js по той же причине.

Новая модель.
- src/lib/keycloak.ts — singleton + initKeycloak(check-sso, PKCE) +
  ensureFreshToken(N) wrapper над kc.updateToken(). Зеркало
  altum/src/lib/keycloak.ts.
- src/stores/authStore.ts — Zustand store: {user, isAuthenticated,
  isLoading, error}. checkAuth() ждёт initKeycloak, wireKeycloakEvents()
  → onAuthSuccess/Refresh/Logout/TokenExpired re-снэпшотят store.
- src/auth/useAuth.ts — compat shim для react-oidc-context API
  (auth.user.profile, isAuthenticated, signinSilent, signinRedirect,
  signoutRedirect, removeUser, error). Все 11 callsites продолжают
  работать без правок (только сменили путь import'а).
- src/api/client.ts — request interceptor дёргает ensureFreshToken(30)
  ПЕРЕД каждым запросом + attaches Bearer ${getToken()}. 401 interceptor
  делает ensureFreshToken(0) + retry один раз. Никаких таймеров, никаких
  setAccessToken/setUnauthorizedHandler holder'ов — keycloak-js сам source
  of truth.
- src/main.tsx — убран <AuthProvider>, <TokenSync />. Просто
  useAuthStore.getState().checkAuth() на старте, потом обычный render.
- public/silent-check-sso.html — endpoint для silentCheckSsoRedirectUri.

Удалены.
- src/auth/TokenSync.tsx — не нужен, ensureFreshToken на запросах.
- src/auth/oidcConfig.ts — не нужен, конфиг внутри lib/keycloak.ts.
- npm deps: react-oidc-context, oidc-client-ts.

Добавлены deps: keycloak-js@26.2.4, zustand@5.0.13.

Tests: 171/171 passed, TSC чистый.

Migration callsites — все unchanged по семантике благодаря compat shim.
2026-05-26 11:18:30 +03:00

80 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type ReactNode } from 'react'
import { useAuth } from '@/auth/useAuth'
/**
* Auth gate с гостевым режимом view-only.
*
* <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()
const oidcError =
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).get('error')
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-title-lg font-sans">Ошибка входа</h1>
<p className="text-body text-ink-2">
Keycloak вернул ошибку:{' '}
<code className="font-mono">{oidcError}</code>
</p>
<button
type="button"
className="mt-4 inline-flex items-center justify-center rounded-sm border border-accent text-accent px-4 py-2 text-body hover:bg-accent-bg"
onClick={() => {
window.location.href = '/'
}}
>
Попробовать снова
</button>
</div>
</div>
)
}
// Keycloak unreachable / OIDC discovery failed — раньше показывали amber
// banner вверху страницы, но per user feedback ("Авторизация недоступна:
// Failed to fetch.?") это noise: anonymous mode работает без auth, error
// banner лишь пугает. Тихо рендерим children в guest mode — AuthBadge
// в TopBar показывает Sign in для retry.
if (auth.error && !auth.isAuthenticated) {
return <>{children}</>
}
// ВАЖНО: НЕ блокируем UI пока {@code auth.isLoading=true}. Initial OIDC
// bootstrap может зависать на 10-20 сек если Keycloak silent SSO iframe
// блокируется X-Frame-Options или сеть медленная. Anonymous user в это
// время видит только spinner и может подумать что сайт сломан.
// Решение: рендерим children immediately. AuthBadge в top bar сам
// показывает loading state своим маленьким индикатором, а PUBLIC данные
// (которые backend отдаёт без auth) грузятся параллельно с silent SSO.
// Если silent SSO succeed позже — useAuth() обновит scope, кнопки
// create/edit активируются автоматически (canMutate реактивен).
// Anonymous OR authenticated OR loading — рендерим children всегда. UI в
// компонентах сам адаптируется (через useAuth().isAuthenticated):
// hide mutation buttons, show «Войти» CTA, тонировать blocked actions.
return <>{children}</>
}