7e435e07b7
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.
80 lines
4.2 KiB
TypeScript
80 lines
4.2 KiB
TypeScript
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}</>
|
||
}
|