feat(auth): implement portal SSO and local admin authentication
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m57s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s

Added support for portal SSO with JWT authentication and local admin login. Updated environment configuration to include AUTH_REQUIRED, AUTH_JWT_SECRET, AUTH_ISSUER, and AUTH_PORTAL_URL. Enhanced the auth plugin to handle JWT verification based on the new configuration. Introduced new routes for authentication and updated the API client to manage token handling and redirects. Improved user experience by integrating authentication checks across various routes and components.
This commit is contained in:
Denozordec
2026-07-18 18:25:29 +07:00
parent 60e15ca40a
commit 6a6cb34eeb
22 changed files with 1101 additions and 97 deletions
+4 -1
View File
@@ -8,6 +8,7 @@ import {
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import {
Sidebar,
SidebarContent,
@@ -106,7 +107,9 @@ export function AppSidebar() {
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
</SidebarContent>
<SidebarFooter />
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
)
}
@@ -9,7 +9,6 @@ import {
BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { ModeToggle } from '@/components/mode-toggle'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
@@ -93,7 +92,7 @@ function useDynamicBreadcrumbLabels() {
}, [matches])
}
/** Header chrome — etalon EvoBGP (Trigger + Separator + Breadcrumb + Apps/Monitor/Mode). */
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
@@ -131,7 +130,6 @@ export function SiteHeader() {
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
)
+169 -27
View File
@@ -1,5 +1,22 @@
import { useNavigate } from '@tanstack/react-router'
import { AppAvatar, AppAvatarFallback } from '@/components/app-avatar'
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
} from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils'
import {
Avatar,
AvatarFallback,
} from '@cfdm/ui/components/avatar'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
@@ -15,16 +32,108 @@ import {
SidebarMenuItem,
useSidebar,
} from '@cfdm/ui/components/sidebar'
import { ChevronsUpDownIcon, LogOutIcon } from 'lucide-react'
import { clearToken } from '@/lib/auth'
import {
can,
clearToken,
getClaims,
isAuthEnabled,
redirectToPortalLogin,
resetPortalHandoff,
} from '@/lib/auth'
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(name: string, email: string): string {
const base = name.trim() || email.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const navigate = useNavigate()
const { isMobile } = useSidebar()
const claims = getClaims()
const authOn = isAuthEnabled()
const handleLogout = () => {
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен')
const fallback = initials(name, email)
function handleSignOut() {
clearToken()
navigate({ to: '/login' })
resetPortalHandoff()
if (authOn) {
redirectToPortalLogin()
return
}
window.location.href = '/login'
}
return (
@@ -33,42 +142,75 @@ export function NavUser() {
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<AppAvatar>
<AppAvatarFallback>АД</AppAvatarFallback>
</AppAvatar>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Администратор</span>
<span className="truncate text-xs">admin</span>
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<AppAvatar>
<AppAvatarFallback>АД</AppAvatarFallback>
</AppAvatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Администратор</span>
<span className="truncate text-xs">admin</span>
</div>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOutIcon />
Выйти
</DropdownMenuItem>
<DropdownMenuGroup>
{can('cfdm:settings:admin') ? (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings/appearance" />}
>
<SettingsIcon aria-hidden />
Настройки
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>