From cef78f9971b9641d61786262a58dadf6da745535 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 4 May 2026 18:28:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20SSO=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20Keycloak=20(PKCE,=20public=20client)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-oidc-context + oidc-client-ts. Confidential client тоже работает — SPA просто не использует client_secret. - src/auth/oidcConfig.ts — UserManager settings (authority, redirect_uri, PKCE, automaticSilentRenew). VITE_KEYCLOAK_{URL,REALM,CLIENT_ID} с дефолтами на nstart realm + ordinis client. - src/auth/TokenSync.tsx — синкает access_token в localStorage и axios default header после login/refresh. Existing API код не трогали — interceptor уже читает localStorage. Bonus: 401 interceptor триггерит signinSilent → signinRedirect fallback. - src/auth/AuthBadge.tsx — header'ный бейдж: "Войти" если anonymous, имя + logout если залогинен. - main.tsx обёрнут в . - __root.tsx показывает AuthBadge рядом с language switch. - .env.example с конфигом Keycloak. - i18n auth.login / auth.logout (RU + EN). Keycloak client `ordinis` — настроить: Valid redirect URIs: https://ordinis.k8s.265.nstart.cloud/*, https://ordinis.k8s.264.nstart.cloud/*, http://localhost:5173/* Web Origins: те же + (или "+"), PKCE Code Challenge: S256 --- ordinis-admin-ui/.env.example | 7 +++ ordinis-admin-ui/package.json | 6 +- ordinis-admin-ui/pnpm-lock.yaml | 32 ++++++++++ ordinis-admin-ui/src/auth/AuthBadge.tsx | 78 +++++++++++++++++++++++++ ordinis-admin-ui/src/auth/TokenSync.tsx | 42 +++++++++++++ ordinis-admin-ui/src/auth/oidcConfig.ts | 41 +++++++++++++ ordinis-admin-ui/src/i18n.ts | 4 ++ ordinis-admin-ui/src/main.tsx | 20 ++++--- ordinis-admin-ui/src/routes/__root.tsx | 4 +- 9 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 ordinis-admin-ui/.env.example create mode 100644 ordinis-admin-ui/src/auth/AuthBadge.tsx create mode 100644 ordinis-admin-ui/src/auth/TokenSync.tsx create mode 100644 ordinis-admin-ui/src/auth/oidcConfig.ts diff --git a/ordinis-admin-ui/.env.example b/ordinis-admin-ui/.env.example new file mode 100644 index 0000000..245b783 --- /dev/null +++ b/ordinis-admin-ui/.env.example @@ -0,0 +1,7 @@ +# API base. По умолчанию — same-origin /api/v1 (через nginx admin-ui pod'а). +VITE_API_BASE=/api/v1 + +# Keycloak OIDC (PKCE flow, public client — secret не нужен). +VITE_KEYCLOAK_URL=https://auth.nstart.space +VITE_KEYCLOAK_REALM=nstart +VITE_KEYCLOAK_CLIENT_ID=ordinis diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index c7b57ea..406fe05 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -21,18 +21,20 @@ "axios": "^1.7.9", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.0.2", + "oidc-client-ts": "^3.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", - "react-i18next": "^17.0.2" + "react-i18next": "^17.0.2", + "react-oidc-context": "^3.3.1" }, "devDependencies": { + "@tailwindcss/vite": "^4.0.0-beta.7", "@tanstack/router-vite-plugin": "^1.86.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0-beta.7", - "@tailwindcss/vite": "^4.0.0-beta.7", "typescript": "^5.7.2", "vite": "^6.0.5", "vitest": "^2.1.9" diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index fbdec23..4dc9965 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: i18next-browser-languagedetector: specifier: ^8.0.2 version: 8.2.1 + oidc-client-ts: + specifier: ^3.5.0 + version: 3.5.0 react: specifier: ^19.0.0 version: 19.2.5 @@ -50,6 +53,9 @@ importers: react-i18next: specifier: ^17.0.2 version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + react-oidc-context: + specifier: ^3.3.1 + version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.5) devDependencies: '@tailwindcss/vite': specifier: ^4.0.0-beta.7 @@ -1173,6 +1179,10 @@ packages: engines: {node: '>=6'} hasBin: true + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1292,6 +1302,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + oidc-client-ts@3.5.0: + resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} + engines: {node: '>=18'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1353,6 +1367,13 @@ packages: typescript: optional: true + 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'} @@ -2526,6 +2547,8 @@ snapshots: json5@2.2.3: {} + jwt-decode@4.0.0: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -2609,6 +2632,10 @@ snapshots: normalize-path@3.0.0: {} + oidc-client-ts@3.5.0: + dependencies: + jwt-decode: 4.0.0 + pathe@1.1.2: {} pathe@2.0.3: {} @@ -2651,6 +2678,11 @@ snapshots: react-dom: 19.2.5(react@19.2.5) typescript: 5.9.3 + 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@19.2.5: {} diff --git a/ordinis-admin-ui/src/auth/AuthBadge.tsx b/ordinis-admin-ui/src/auth/AuthBadge.tsx new file mode 100644 index 0000000..9d5a648 --- /dev/null +++ b/ordinis-admin-ui/src/auth/AuthBadge.tsx @@ -0,0 +1,78 @@ +import { useAuth } from 'react-oidc-context' +import { useTranslation } from 'react-i18next' +import { Button, IconButton } from '@nstart/ui' +import { SignInIcon, SignOutIcon, UserIcon } from '@phosphor-icons/react' + +/** + * Бейдж в header'е: «Войти» если anonymous; имя + logout если залогинен. + * + *

Logout жмёт {@code signoutRedirect} — Keycloak инвалидирует SSO-сессию и + * вернёт на post-logout-uri (корень). + */ +export function AuthBadge() { + const { t } = useTranslation() + const auth = useAuth() + + if (auth.isLoading) { + return ( + + {t('loading')} + + ) + } + + if (auth.error) { + // Не валим UI — просто кнопка для повтора login. + return ( + + ) + } + + if (!auth.isAuthenticated) { + return ( + + ) + } + + const profile = auth.user?.profile + const display = + profile?.preferred_username || + profile?.name || + profile?.email || + profile?.sub || + 'user' + + return ( +

+ + + {display} + + } + label={t('auth.logout')} + onClick={() => + auth.signoutRedirect().catch(() => { + // Если Keycloak недоступен — просто очищаем локальный state. + auth.removeUser() + }) + } + /> +
+ ) +} diff --git a/ordinis-admin-ui/src/auth/TokenSync.tsx b/ordinis-admin-ui/src/auth/TokenSync.tsx new file mode 100644 index 0000000..43b1621 --- /dev/null +++ b/ordinis-admin-ui/src/auth/TokenSync.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react' +import { useAuth } from 'react-oidc-context' +import { apiClient } from '@/api/client' +import { TOKEN_STORAGE_KEY } from './oidcConfig' + +/** + * Синхронизирует {@code access_token} из react-oidc-context в localStorage и + * axios default header. Existing API код ничего не знает про OIDC — просто + * читает токен через axios interceptor. + * + *

Mount внутри {@code }, рендерит null. + */ +export function TokenSync() { + const auth = useAuth() + + useEffect(() => { + const token = auth.user?.access_token + if (token) { + localStorage.setItem(TOKEN_STORAGE_KEY, token) + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}` + } else { + localStorage.removeItem(TOKEN_STORAGE_KEY) + delete apiClient.defaults.headers.common['Authorization'] + } + }, [auth.user?.access_token]) + + // Авто-логин на 401 — токен мог истечь и silent renew не успел. + useEffect(() => { + const id = apiClient.interceptors.response.use( + (r) => r, + (err) => { + if (err.response?.status === 401 && auth.isAuthenticated) { + auth.signinSilent().catch(() => auth.signinRedirect()) + } + return Promise.reject(err) + }, + ) + return () => apiClient.interceptors.response.eject(id) + }, [auth]) + + return null +} diff --git a/ordinis-admin-ui/src/auth/oidcConfig.ts b/ordinis-admin-ui/src/auth/oidcConfig.ts new file mode 100644 index 0000000..fbc4a3f --- /dev/null +++ b/ordinis-admin-ui/src/auth/oidcConfig.ts @@ -0,0 +1,41 @@ +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, + userStore: + typeof window !== 'undefined' + ? new WebStorageStateStore({ store: window.localStorage }) + : undefined, +} + +/** + * Конфиг для {@code }. PKCE flow, public client — secret не нужен. + * + *

{@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) + } + }, +} + +export const TOKEN_STORAGE_KEY = 'ordinis.token' diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 580c4cc..ed596c9 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -172,6 +172,8 @@ i18n 'field.frequency_bands': 'Частотные диапазоны', 'loading': 'Загрузка...', 'error.failed': 'Не удалось загрузить данные', + 'auth.login': 'Войти', + 'auth.logout': 'Выйти', }, }, 'en-US': { @@ -336,6 +338,8 @@ i18n 'field.frequency_bands': 'Frequency bands', 'loading': 'Loading...', 'error.failed': 'Failed to load data', + 'auth.login': 'Sign in', + 'auth.logout': 'Sign out', }, }, }, diff --git a/ordinis-admin-ui/src/main.tsx b/ordinis-admin-ui/src/main.tsx index 8e8386c..5896b9a 100644 --- a/ordinis-admin-ui/src/main.tsx +++ b/ordinis-admin-ui/src/main.tsx @@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RouterProvider, createRouter } from '@tanstack/react-router' import { NStartUiProvider, DatePickerProvider, DATE_PICKER_LOCALE_RU } from '@nstart/ui' +import { AuthProvider } from 'react-oidc-context' import { routeTree } from './routeTree.gen' +import { oidcAuthConfig } from '@/auth/oidcConfig' +import { TokenSync } from '@/auth/TokenSync' import './i18n' import './styles.css' @@ -23,12 +26,15 @@ declare module '@tanstack/react-router' { createRoot(document.getElementById('root')!).render( - - - - - - - + + + + + + + + + + , ) diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index ebed37a..c922e41 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -2,6 +2,7 @@ import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router import type { QueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { LanguageSwitch } from '@nstart/ui' +import { AuthBadge } from '@/auth/AuthBadge' export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ component: RootLayout, @@ -44,12 +45,13 @@ function RootLayout() { {t('nav.outbox')} -

+
i18n.changeLanguage(id)} /> +