diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index f296c8b..aa464a6 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -40,9 +40,9 @@ "d3-geo": "^3.1.1", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.0.2", + "keycloak-js": "^26.2.4", "leaflet": "^1.9.4", "lucide-react": "^1.14.0", - "oidc-client-ts": "^3.5.0", "react": "^19.0.0", "react-day-picker": "^10.0.0", "react-dom": "^19.0.0", @@ -50,13 +50,13 @@ "react-hook-form": "^7.54.2", "react-i18next": "^17.0.2", "react-leaflet": "^5.0.0", - "react-oidc-context": "^3.3.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "three": "^0.184.0", "topojson-client": "^3.1.0", "tw-animate-css": "^1.4.0", - "world-atlas": "^2.0.2" + "world-atlas": "^2.0.2", + "zustand": "^5.0.13" }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index 2bb8b3a..c686e44 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -83,15 +83,15 @@ importers: i18next-browser-languagedetector: specifier: ^8.0.2 version: 8.2.1 + keycloak-js: + specifier: ^26.2.4 + version: 26.2.4 leaflet: specifier: ^1.9.4 version: 1.9.4 lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.5) - oidc-client-ts: - specifier: ^3.5.0 - version: 3.5.0 react: specifier: ^19.0.0 version: 19.2.5 @@ -113,9 +113,6 @@ importers: react-leaflet: specifier: ^5.0.0 version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-oidc-context: - specifier: ^3.3.1 - version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.5) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -134,6 +131,9 @@ importers: world-atlas: specifier: ^2.0.2 version: 2.0.2 + zustand: + specifier: ^5.0.13 + version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) devDependencies: '@semantic-release/changelog': specifier: ^6.0.3 @@ -2691,14 +2691,13 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - jwt-decode@4.0.0: - resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} - engines: {node: '>=18'} - kapsule@1.16.3: resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==} engines: {node: '>=12'} + keycloak-js@26.2.4: + resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==} + keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} @@ -3065,10 +3064,6 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - oidc-client-ts@3.5.0: - resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} - engines: {node: '>=18'} - onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -3322,13 +3317,6 @@ packages: react: ^19.0.0 react-dom: ^19.0.0 - react-oidc-context@3.3.1: - resolution: {integrity: sha512-/Azvm9W4DhhOtSDBE73kFInh1b6zZRRfILKbgmk2syExMF0PCYJOn/dGdOOi2BFX8x0rCeUe45NXHU+/+xDcrQ==} - engines: {node: '>=18'} - peerDependencies: - oidc-client-ts: ^3.1.0 - react: '>=16.14.0' - react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -3986,6 +3974,24 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@5.0.13: + resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: 3d-force-graph@1.80.0: @@ -6363,12 +6369,12 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jwt-decode@4.0.0: {} - kapsule@1.16.3: dependencies: lodash-es: 4.18.1 + keycloak-js@26.2.4: {} + keyv@5.6.0: dependencies: '@keyv/serialize': 1.1.1 @@ -6605,10 +6611,6 @@ snapshots: object-assign@4.1.1: {} - oidc-client-ts@3.5.0: - dependencies: - jwt-decode: 4.0.0 - onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -6820,11 +6822,6 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - react-oidc-context@3.3.1(oidc-client-ts@3.5.0)(react@19.2.5): - dependencies: - oidc-client-ts: 3.5.0 - react: 19.2.5 - react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): @@ -7440,3 +7437,9 @@ snapshots: yoctocolors@2.1.2: {} zod@3.25.76: {} + + zustand@5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) diff --git a/ordinis-admin-ui/public/silent-check-sso.html b/ordinis-admin-ui/public/silent-check-sso.html new file mode 100644 index 0000000..2762a8c --- /dev/null +++ b/ordinis-admin-ui/public/silent-check-sso.html @@ -0,0 +1,11 @@ + + +
+ + + diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 4800f4d..a8cc54c 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -1,40 +1,5 @@ import axios from 'axios' - -/** - * Source of truth для access token. {@link TokenSync} обновляет это значение - * синхронно при изменении OIDC user state. Interceptor читает из in-memory - * holder'а — никаких {@code defaults.headers}, никаких {@code localStorage} poll'ов. - * - *Зачем in-memory holder, не localStorage: - *
Mount внутри {@code Single source of truth для 401: раньше был дубликат interceptor'а
- * в {@code api/client.ts} (просто удалял token из localStorage) + ещё один
- * здесь (signinSilent/Redirect). Оба срабатывали на одном response — race и
- * потенциальный redirect loop. Теперь handler один, через
- * {@link setUnauthorizedHandler}.
- *
- * Loop guard: {@code redirecting} ref предотвращает burst
- * параллельных signinRedirect'ов при N concurrent 401 (типичная картина
- * после истечения токена — все pending requests возвращают 401 одновременно).
- */
-export function TokenSync() {
- const auth = useAuth()
- const redirecting = useRef(false)
-
- // 1) Token sync — single update path к accessToken holder в client.ts.
- // ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token
- // не expired. Раньше передавали raw {@code auth.user?.access_token} — это
- // включало случаи когда oidc-client-ts вернул user object с уже expired
- // token'ом (silent renew failed но user object всё ещё в state). axios
- // отправлял Bearer expired_token → backend (с permissive=false для guest
- // anyway re-validates JWT через oauth2ResourceServer) → 401 → catalog UI
- // показывал «Не удалось загрузить данные» вместо PUBLIC dicts.
- //
- // user.expired property из oidc-client-ts: true если access_token expiry
- // прошла. Дополнительно проверяем isAuthenticated — react-oidc-context
- // обновляет это в реальном времени.
- useEffect(() => {
- const liveToken =
- auth.isAuthenticated && auth.user && !auth.user.expired
- ? auth.user.access_token
- : null
- setAccessToken(liveToken ?? null)
- // Cleanup legacy localStorage key (was used до fix #12 + #2/3/13 cascade).
- // Идемпотентный no-op если ключа нет.
- if (typeof window !== 'undefined') {
- window.localStorage.removeItem(LEGACY_TOKEN_STORAGE_KEY)
- }
- }, [auth.isAuthenticated, auth.user, auth.user?.access_token, auth.user?.expired])
-
- // 2) 401 handler — registered once на app mount, делегирует silent renew /
- // redirect на Keycloak. Guard'ы:
- // - auth.isLoading / activeNavigator: OIDC bootstrap или redirect уже в
- // progress — не дублируем.
- // - URL содержит ?error=: вернулись из Keycloak с провалом — пусть UI
- // покажет RequireAuth error screen, не зацикливаемся.
- // - redirecting ref: при burst 401 (N concurrent requests) первый
- // handler инициирует redirect, остальные no-op до его завершения.
- useEffect(() => {
- setUnauthorizedHandler(() => {
- if (auth.isLoading || auth.activeNavigator) return
- if (typeof window !== 'undefined' && window.location.search.includes('error=')) {
- return
- }
- if (redirecting.current) return
- redirecting.current = true
- const reset = () => {
- redirecting.current = false
- }
- if (auth.isAuthenticated) {
- // Authenticated user hit 401 → token истёк. Silent renew, иначе full
- // redirect для re-login.
- auth
- .signinSilent()
- .then(reset)
- .catch(() => {
- // Silent renew не удался — full redirect. reset не нужен:
- // page reload сбросит state. Но catch всё равно нужен на случай
- // если redirect promise отвергается (Keycloak unreachable).
- auth.signinRedirect().catch(reset)
- })
- } else {
- // 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)
- }, [auth])
-
- return null
-}
diff --git a/ordinis-admin-ui/src/auth/oidcConfig.ts b/ordinis-admin-ui/src/auth/oidcConfig.ts
deleted file mode 100644
index 12cee86..0000000
--- a/ordinis-admin-ui/src/auth/oidcConfig.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { WebStorageStateStore, type User, type UserManagerSettings } from 'oidc-client-ts'
-import type { AuthProviderProps } from 'react-oidc-context'
-
-const KC_URL = import.meta.env.VITE_KEYCLOAK_URL ?? 'https://auth.nstart.space'
-const KC_REALM = import.meta.env.VITE_KEYCLOAK_REALM ?? 'nstart'
-const KC_CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'ordinis'
-
-const authority = `${KC_URL}/realms/${KC_REALM}`
-
-const baseSettings: UserManagerSettings = {
- authority,
- client_id: KC_CLIENT_ID,
- redirect_uri: typeof window !== 'undefined' ? window.location.origin + '/' : '/',
- post_logout_redirect_uri:
- typeof window !== 'undefined' ? window.location.origin + '/' : '/',
- scope: 'openid profile email',
- response_type: 'code',
- automaticSilentRenew: true,
- loadUserInfo: false,
- // sessionStorage вместо localStorage: уменьшает XSS persistent reach на
- // access_token + id_token. Trade-off: токен не переживает закрытие вкладки —
- // на повторном открытии будет SSO redirect к Keycloak (но без user input
- // если SSO session в Keycloak ещё жива, т.е. UX impact минимальный).
- // Долгосрочное правильное решение — BFF + httpOnly cookie с refresh token,
- // но это отдельный epic (server-side OIDC handler).
- // См. также {@code apiClient}: access_token держится в in-memory holder'е,
- // не читается из storage на каждый request.
- userStore:
- typeof window !== 'undefined'
- ? new WebStorageStateStore({ store: window.sessionStorage })
- : undefined,
-}
-
-/**
- * Конфиг для {@code {@code onSigninCallback} удаляет {@code ?code=...&state=...} из URL после
- * успешного login, чтобы TanStack Router не запутался.
- */
-export const oidcAuthConfig: AuthProviderProps = {
- ...baseSettings,
- onSigninCallback: (_user: User | void) => {
- if (typeof window !== 'undefined') {
- window.history.replaceState({}, document.title, window.location.pathname)
- }
- },
-}
-
-// TOKEN_STORAGE_KEY (legacy 'ordinis.token' в localStorage) удалён вместе с
-// fix дублирующих 401-interceptor'ов. Access token больше не дублируется
-// в localStorage из приложения — он живёт в memory holder'е (см. apiClient
-// setAccessToken) и oidc-client-ts userStore (sessionStorage, fix #12).
-//
-// Cleanup: на первый mount old клиенты могут иметь старый ключ — TokenSync
-// его удаляет.
-export const LEGACY_TOKEN_STORAGE_KEY = 'ordinis.token'
diff --git a/ordinis-admin-ui/src/auth/useAuth.ts b/ordinis-admin-ui/src/auth/useAuth.ts
new file mode 100644
index 0000000..76a809d
--- /dev/null
+++ b/ordinis-admin-ui/src/auth/useAuth.ts
@@ -0,0 +1,53 @@
+import { useAuthStore } from '@/stores/authStore'
+import { ensureFreshToken, startLogin, startLogout } from '@/lib/keycloak'
+
+/**
+ * Compat-shim для `useAuth` API из react-oidc-context. Минимальный
+ * superset того, что использовалось в codebase (см. RequireAuth,
+ * useUserDisplay, Sidebar, MyDraftsPanel, reviews, etc.):
+ * - `auth.user.profile.sub` / `preferred_username` / `email` / `name`
+ * - `auth.user.expired`
+ * - `auth.user.access_token`
+ * - `auth.isAuthenticated` / `auth.isLoading`
+ * - `auth.signinSilent()` / `auth.signinRedirect()` / `auth.signoutRedirect()`
+ * - `auth.activeNavigator` (всегда undefined в keycloak-js модели)
+ *
+ * Поверх zustand `useAuthStore` — реактивно обновляется на
+ * keycloak-js callbacks (onAuthSuccess, onAuthRefreshSuccess, onTokenExpired,
+ * onAuthLogout). Permissions / role checks остаются работать без изменений.
+ */
+export const useAuth = () => {
+ const user = useAuthStore((s) => s.user)
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
+ const isLoading = useAuthStore((s) => s.isLoading)
+ const error = useAuthStore((s) => s.error)
+ return {
+ user,
+ isAuthenticated,
+ isLoading,
+ error,
+ activeNavigator: undefined,
+ /** Forced silent refresh — wraps ensureFreshToken(0). На failure
+ * keycloak-js делает kc.login() автоматически → user редиректится. */
+ signinSilent: async () => {
+ await ensureFreshToken(0)
+ return useAuthStore.getState().user
+ },
+ signinRedirect: async () => {
+ await startLogin()
+ },
+ signoutRedirect: async () => {
+ await startLogout()
+ },
+ /** Local-only logout (clear store без redirect на Keycloak). Зеркало
+ * `react-oidc-context removeUser` — callsites используют для "забыть
+ * user" без full logout flow. В keycloak-js модели соответствует
+ * очистке локального state — следующий ensureFreshToken покажет
+ * Login. */
+ removeUser: async () => {
+ const { user, ...rest } = useAuthStore.getState()
+ void user
+ useAuthStore.setState({ ...rest, user: null, isAuthenticated: false })
+ },
+ }
+}
diff --git a/ordinis-admin-ui/src/auth/useCanMutate.ts b/ordinis-admin-ui/src/auth/useCanMutate.ts
index 43cfa21..958b5ca 100644
--- a/ordinis-admin-ui/src/auth/useCanMutate.ts
+++ b/ordinis-admin-ui/src/auth/useCanMutate.ts
@@ -1,4 +1,4 @@
-import { useAuth } from 'react-oidc-context'
+import { useAuth } from '@/auth/useAuth'
/**
* Может ли текущий пользователь выполнять mutation операции (create / update /
diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts
index f77ef7b..84af018 100644
--- a/ordinis-admin-ui/src/auth/usePermissions.ts
+++ b/ordinis-admin-ui/src/auth/usePermissions.ts
@@ -1,4 +1,4 @@
-import { useAuth } from 'react-oidc-context'
+import { useAuth } from '@/auth/useAuth'
/**
* Permission helpers для schema + record workflow. Phase 1d — **ACTIVE**:
diff --git a/ordinis-admin-ui/src/components/layout/Sidebar.tsx b/ordinis-admin-ui/src/components/layout/Sidebar.tsx
index ca58234..8deaa1f 100644
--- a/ordinis-admin-ui/src/components/layout/Sidebar.tsx
+++ b/ordinis-admin-ui/src/components/layout/Sidebar.tsx
@@ -11,7 +11,7 @@ import {
Search,
type LucideIcon,
} from 'lucide-react'
-import { useAuth } from 'react-oidc-context'
+import { useAuth } from '@/auth/useAuth'
import { useCanMutate } from '@/auth/useCanMutate'
import {
useCanReviewRecord,
diff --git a/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx
index 11c43ea..4799d1a 100644
--- a/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx
+++ b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
-import { useAuth } from 'react-oidc-context'
+import { useAuth } from '@/auth/useAuth'
import {
Badge,
Button,
diff --git a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx
index 29a21f8..a7b5a52 100644
--- a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx
+++ b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { useAuth } from 'react-oidc-context'
+import { useAuth } from '@/auth/useAuth'
import axios from 'axios'
import { Badge, Button, Drawer, LoadingBlock, QueryErrorState, TextArea, toast } from '@/ui'
import {
diff --git a/ordinis-admin-ui/src/lib/keycloak.ts b/ordinis-admin-ui/src/lib/keycloak.ts
new file mode 100644
index 0000000..d0e0c36
--- /dev/null
+++ b/ordinis-admin-ui/src/lib/keycloak.ts
@@ -0,0 +1,124 @@
+import Keycloak from 'keycloak-js'
+
+/**
+ * Ordinis Keycloak OIDC integration. Зеркало паттерна altum
+ * (`altum/src/lib/keycloak.ts`) — keycloak-js + explicit `updateToken`
+ * вместо oidc-client-ts `automaticSilentRenew` (последний триггерил
+ * бесконечную петлю POST /token когда refresh_token истекал, см.
+ * MR !269 revert).
+ *
+ * Default URL = `auth.nstart.space/realms/nstart/clients/ordinis` —
+ * текущий ordinis realm. Phase 2 (отдельный эпик) — миграция на
+ * `altum.nstart.cloud/auth/realms/altum` если решим SSO с altum.
+ */
+const KC_URL = import.meta.env.VITE_KEYCLOAK_URL ?? 'https://auth.nstart.space'
+const KC_REALM = import.meta.env.VITE_KEYCLOAK_REALM ?? 'nstart'
+const KC_CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'ordinis'
+
+let _kc: Keycloak | null = null
+let _initPromise: Promise Pattern из altum (`altum/src/store/authStore.ts`):
+ * `checkAuth()` дожидается `initKeycloak`, забирает claims из
+ * `kc.tokenParsed`, кладёт user-info в store. `login/logout` — обёртки
+ * над keycloak-js.
+ *
+ * Поддерживает изоморфный API с предыдущим `useAuth()` из
+ * react-oidc-context — см. `auth/useAuth.ts` compat-shim.
+ */
+
+export interface KeycloakProfile {
+ sub?: string
+ email?: string
+ name?: string
+ preferred_username?: string
+ picture?: string
+ realm_access?: { roles?: string[] }
+ [key: string]: unknown
+}
+
+export interface AuthUser {
+ profile: KeycloakProfile
+ access_token: string | undefined
+ expired: boolean
+}
+
+interface AuthStore {
+ user: AuthUser | null
+ isAuthenticated: boolean
+ isLoading: boolean
+ /** Error from initKeycloak (Keycloak unreachable / OIDC discovery failed).
+ * Mirrors react-oidc-context `auth.error` field — RequireAuth использует
+ * для тихого fallback'а в guest mode. */
+ error: Error | null
+ checkAuth: () => Promise