feat(admin-ui): SSO через Keycloak (PKCE, public client)
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 обёрнут в <AuthProvider>.
- __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
This commit is contained in:
@@ -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
|
||||||
@@ -21,18 +21,20 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
|
"oidc-client-ts": "^3.5.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-i18next": "^17.0.2"
|
"react-i18next": "^17.0.2",
|
||||||
|
"react-oidc-context": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0-beta.7",
|
||||||
"@tanstack/router-vite-plugin": "^1.86.0",
|
"@tanstack/router-vite-plugin": "^1.86.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"tailwindcss": "^4.0.0-beta.7",
|
"tailwindcss": "^4.0.0-beta.7",
|
||||||
"@tailwindcss/vite": "^4.0.0-beta.7",
|
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
|
|||||||
Generated
+32
@@ -38,6 +38,9 @@ importers:
|
|||||||
i18next-browser-languagedetector:
|
i18next-browser-languagedetector:
|
||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.2.1
|
version: 8.2.1
|
||||||
|
oidc-client-ts:
|
||||||
|
specifier: ^3.5.0
|
||||||
|
version: 3.5.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.5
|
version: 19.2.5
|
||||||
@@ -50,6 +53,9 @@ importers:
|
|||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^17.0.2
|
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)
|
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:
|
devDependencies:
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.0-beta.7
|
specifier: ^4.0.0-beta.7
|
||||||
@@ -1173,6 +1179,10 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jwt-decode@4.0.0:
|
||||||
|
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -1292,6 +1302,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
oidc-client-ts@3.5.0:
|
||||||
|
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
pathe@1.1.2:
|
pathe@1.1.2:
|
||||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||||
|
|
||||||
@@ -1353,6 +1367,13 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
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:
|
react-refresh@0.17.0:
|
||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2526,6 +2547,8 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jwt-decode@4.0.0: {}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2609,6 +2632,10 @@ snapshots:
|
|||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
|
oidc-client-ts@3.5.0:
|
||||||
|
dependencies:
|
||||||
|
jwt-decode: 4.0.0
|
||||||
|
|
||||||
pathe@1.1.2: {}
|
pathe@1.1.2: {}
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
@@ -2651,6 +2678,11 @@ snapshots:
|
|||||||
react-dom: 19.2.5(react@19.2.5)
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
typescript: 5.9.3
|
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-refresh@0.17.0: {}
|
||||||
|
|
||||||
react@19.2.5: {}
|
react@19.2.5: {}
|
||||||
|
|||||||
@@ -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 если залогинен.
|
||||||
|
*
|
||||||
|
* <p>Logout жмёт {@code signoutRedirect} — Keycloak инвалидирует SSO-сессию и
|
||||||
|
* вернёт на post-logout-uri (корень).
|
||||||
|
*/
|
||||||
|
export function AuthBadge() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
if (auth.isLoading) {
|
||||||
|
return (
|
||||||
|
<span className="text-2xs uppercase tracking-label text-carbon/60">
|
||||||
|
{t('loading')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.error) {
|
||||||
|
// Не валим UI — просто кнопка для повтора login.
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon={<SignInIcon size={16} />}
|
||||||
|
onClick={() => auth.signinRedirect()}
|
||||||
|
>
|
||||||
|
{t('auth.login')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<SignInIcon size={16} />}
|
||||||
|
onClick={() => auth.signinRedirect()}
|
||||||
|
>
|
||||||
|
{t('auth.login')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = auth.user?.profile
|
||||||
|
const display =
|
||||||
|
profile?.preferred_username ||
|
||||||
|
profile?.name ||
|
||||||
|
profile?.email ||
|
||||||
|
profile?.sub ||
|
||||||
|
'user'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm text-carbon">
|
||||||
|
<UserIcon size={14} weight="duotone" />
|
||||||
|
<span className="hidden sm:inline">{display}</span>
|
||||||
|
</span>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon={<SignOutIcon weight="bold" />}
|
||||||
|
label={t('auth.logout')}
|
||||||
|
onClick={() =>
|
||||||
|
auth.signoutRedirect().catch(() => {
|
||||||
|
// Если Keycloak недоступен — просто очищаем локальный state.
|
||||||
|
auth.removeUser()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Mount внутри {@code <AuthProvider>}, рендерит 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
|
||||||
|
}
|
||||||
@@ -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 <AuthProvider>}. PKCE flow, public client — secret не нужен.
|
||||||
|
*
|
||||||
|
* <p>{@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'
|
||||||
@@ -172,6 +172,8 @@ i18n
|
|||||||
'field.frequency_bands': 'Частотные диапазоны',
|
'field.frequency_bands': 'Частотные диапазоны',
|
||||||
'loading': 'Загрузка...',
|
'loading': 'Загрузка...',
|
||||||
'error.failed': 'Не удалось загрузить данные',
|
'error.failed': 'Не удалось загрузить данные',
|
||||||
|
'auth.login': 'Войти',
|
||||||
|
'auth.logout': 'Выйти',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'en-US': {
|
'en-US': {
|
||||||
@@ -336,6 +338,8 @@ i18n
|
|||||||
'field.frequency_bands': 'Frequency bands',
|
'field.frequency_bands': 'Frequency bands',
|
||||||
'loading': 'Loading...',
|
'loading': 'Loading...',
|
||||||
'error.failed': 'Failed to load data',
|
'error.failed': 'Failed to load data',
|
||||||
|
'auth.login': 'Sign in',
|
||||||
|
'auth.logout': 'Sign out',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
import { NStartUiProvider, DatePickerProvider, DATE_PICKER_LOCALE_RU } from '@nstart/ui'
|
import { NStartUiProvider, DatePickerProvider, DATE_PICKER_LOCALE_RU } from '@nstart/ui'
|
||||||
|
import { AuthProvider } from 'react-oidc-context'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
|
import { oidcAuthConfig } from '@/auth/oidcConfig'
|
||||||
|
import { TokenSync } from '@/auth/TokenSync'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
@@ -23,12 +26,15 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AuthProvider {...oidcAuthConfig}>
|
||||||
<NStartUiProvider>
|
<TokenSync />
|
||||||
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<NStartUiProvider>
|
||||||
</DatePickerProvider>
|
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
|
||||||
</NStartUiProvider>
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</DatePickerProvider>
|
||||||
|
</NStartUiProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router
|
|||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LanguageSwitch } from '@nstart/ui'
|
import { LanguageSwitch } from '@nstart/ui'
|
||||||
|
import { AuthBadge } from '@/auth/AuthBadge'
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@@ -44,12 +45,13 @@ function RootLayout() {
|
|||||||
{t('nav.outbox')}
|
{t('nav.outbox')}
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<LanguageSwitch
|
<LanguageSwitch
|
||||||
value={i18n.language}
|
value={i18n.language}
|
||||||
options={LANG_OPTIONS}
|
options={LANG_OPTIONS}
|
||||||
onChange={(id) => i18n.changeLanguage(id)}
|
onChange={(id) => i18n.changeLanguage(id)}
|
||||||
/>
|
/>
|
||||||
|
<AuthBadge />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user