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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user