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 ( + } + onClick={() => auth.signinRedirect()} + > + {t('auth.login')} + + ) + } + + if (!auth.isAuthenticated) { + return ( + } + onClick={() => auth.signinRedirect()} + > + {t('auth.login')} + + ) + } + + const profile = auth.user?.profile + const display = + profile?.preferred_username || + profile?.name || + profile?.email || + profile?.sub || + 'user' + + return ( +
Mount внутри {@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)
+ }
+ },
+}
+
+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(