feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# EvoBGP web (Vite) — переменные окружения.
|
||||
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
|
||||
# ---------------------------------------------------------------------------
|
||||
# REUI_LICENSE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
|
||||
# недоступен либо VITE_AUTH_ENABLED=false).
|
||||
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
|
||||
# ---------------------------------------------------------------------------
|
||||
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-portal SSO
|
||||
# ---------------------------------------------------------------------------
|
||||
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
|
||||
# Пример:
|
||||
# VITE_AUTH_ENABLED=true
|
||||
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
#
|
||||
# Backend опционально может отдавать GET /v1/auth/config
|
||||
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
|
||||
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
|
||||
# VITE_AUTH_ENABLED=false
|
||||
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||
@@ -45,8 +45,10 @@ import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
@@ -54,6 +56,8 @@ interface NavItem {
|
||||
icon: ComponentType<{ className?: string }>
|
||||
description?: string
|
||||
search?: Record<string, string>
|
||||
/** Explicit permission override; when omitted derived from `to`. */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -103,6 +107,18 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
|
||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
/** Nav visible for the current claim (or all items when portal auth is off). */
|
||||
function useVisibleNavGroups(): NavGroup[] {
|
||||
if (!isAuthEnabled()) return NAV_GROUPS
|
||||
return NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => {
|
||||
const perm = item.permission ?? permissionForPath(item.to)
|
||||
return !perm || can(perm)
|
||||
}),
|
||||
})).filter((group) => group.items.length > 0)
|
||||
}
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
@@ -127,6 +143,7 @@ const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const visibleGroups = useVisibleNavGroups()
|
||||
const activeItem =
|
||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||
ALL_NAV_ITEMS[0]
|
||||
@@ -152,7 +169,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
@@ -178,7 +195,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CURRENT_APP_ID,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
|
||||
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppsMenu() {
|
||||
@@ -79,13 +80,23 @@ export function AppsMenu() {
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
{isAuthEnabled() ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
ExternalLinkIcon,
|
||||
LogOutIcon,
|
||||
SettingsIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
|
||||
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import {
|
||||
authPortalUrl,
|
||||
clearPortalToken,
|
||||
getClaims,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogout,
|
||||
resetPortalHandoff,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Sidebar footer account menu.
|
||||
* Portal mode → shows JWT email + logout via auth-portal.
|
||||
* Local mode → shows the API-key hint + clears the local token.
|
||||
* @see https://reui.io/preview/base/app-shell-12
|
||||
*/
|
||||
function initials(source: string): string {
|
||||
const base = source.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 { isMobile } = useSidebar()
|
||||
const authOn = isAuthEnabled()
|
||||
const claims = getClaims()
|
||||
|
||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||
const email =
|
||||
claims?.email?.trim() ||
|
||||
(authOn ? '' : 'локальный API-токен')
|
||||
const fallback = initials(name || email)
|
||||
|
||||
function handleSignOut() {
|
||||
if (authOn) {
|
||||
clearPortalToken()
|
||||
resetPortalHandoff()
|
||||
redirectToPortalLogout()
|
||||
return
|
||||
}
|
||||
setApiToken(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||
window.location.assign('/settings?tab=connection&reason=token-required')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<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 />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки UI
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden />
|
||||
Открыть Auth Portal
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOutIcon aria-hidden />
|
||||
{authOn ? 'Выйти' : 'Сбросить токен'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,42 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
DEFAULT_APP_SWITCHER_CONFIG,
|
||||
getAppSwitcherConfig,
|
||||
getAppUrl as getAppUrlFromConfig,
|
||||
type AppSwitcherConfig,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||
import { getClaims, isAuthEnabled } from '@/lib/auth'
|
||||
|
||||
/** Env-backed app switcher (no DB API in EvoBGP v1). */
|
||||
/**
|
||||
* Portal-first switcher: tries `/api/v1/app-switcher` and filters entries by
|
||||
* the current JWT `apps` claim; falls back to `VITE_APP_SWITCHER`/defaults
|
||||
* when auth-portal is disabled or unavailable.
|
||||
*/
|
||||
export function useAppSwitcherConfig(): {
|
||||
config: AppSwitcherConfig
|
||||
isLoading: boolean
|
||||
} {
|
||||
return {
|
||||
config: getAppSwitcherConfig(),
|
||||
isLoading: false,
|
||||
}
|
||||
const authOn = isAuthEnabled()
|
||||
const { data, isLoading } = useQuery({
|
||||
...appSwitcherQueryOptions(),
|
||||
enabled: authOn,
|
||||
})
|
||||
const claims = getClaims()
|
||||
|
||||
const config = useMemo<AppSwitcherConfig>(() => {
|
||||
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
|
||||
if (!authOn) return raw
|
||||
const allowed = claims?.apps
|
||||
if (!allowed?.length) return raw
|
||||
const set = new Set(allowed)
|
||||
const apps = raw.apps.filter((a) => set.has(a.id))
|
||||
if (!apps.length) return raw
|
||||
return { ...raw, apps }
|
||||
}, [data, authOn, claims?.apps])
|
||||
|
||||
return { config, isLoading: authOn && isLoading }
|
||||
}
|
||||
|
||||
export function useAppUrl(appId: string): string | undefined {
|
||||
|
||||
@@ -30,8 +30,19 @@ export function normalizeApiToken(raw: string): string {
|
||||
return t
|
||||
}
|
||||
|
||||
/** Portal JWT storage key mirrored from `@/lib/auth`. Kept local to avoid a
|
||||
* cycle when `auth` starts pulling from `api-client` for the config endpoint. */
|
||||
const PORTAL_TOKEN_STORAGE_KEY = 'evobgp_portal_token'
|
||||
|
||||
/**
|
||||
* Bearer selection: portal JWT wins over the legacy API-key. When auth-portal
|
||||
* is disabled or hasn't issued a token yet we fall back to the local API-key
|
||||
* (`evobgp_api_token`) so curl-style tooling keeps working.
|
||||
*/
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const portal = window.localStorage.getItem(PORTAL_TOKEN_STORAGE_KEY)
|
||||
if (portal && portal.trim()) return portal.trim()
|
||||
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const normalized = normalizeApiToken(raw)
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CURRENT_APP_ID = 'evobgp'
|
||||
import { CURRENT_APP_ID } from '@/lib/auth'
|
||||
|
||||
export { CURRENT_APP_ID }
|
||||
|
||||
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
|
||||
|
||||
@@ -44,7 +46,7 @@ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||
menuLabel: 'Приложения',
|
||||
apps: [
|
||||
{
|
||||
id: 'vps-tracker',
|
||||
id: 'vps',
|
||||
name: 'VPS Tracker',
|
||||
subtitle: 'Учёт виртуальных серверов',
|
||||
url: 'http://192.168.100.67:3001',
|
||||
@@ -60,7 +62,7 @@ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||
shortcut: '⌘2',
|
||||
},
|
||||
{
|
||||
id: 'evobgp',
|
||||
id: 'bgp',
|
||||
name: 'EvoBGP',
|
||||
subtitle: 'BGP маршрутизация',
|
||||
url: 'http://192.168.100.67:3000',
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Portal JWT SSO integration for EvoBGP UI.
|
||||
*
|
||||
* Two independent auth channels:
|
||||
* - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer
|
||||
* to EvoBGP API when the API accepts portal-issued tokens.
|
||||
* - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token`
|
||||
* in localStorage; used when auth-portal is disabled or as a fallback.
|
||||
*
|
||||
* `VITE_AUTH_ENABLED=false` → keep the API-key gate.
|
||||
* `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools
|
||||
* (curl/dev) and as backup.
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'evobgp_portal_token'
|
||||
const HANDOFF_KEY = 'evobgp_portal_401_handoff'
|
||||
const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at'
|
||||
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
|
||||
const HANDOFF_COOLDOWN_MS = 12_000
|
||||
|
||||
/** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */
|
||||
export const CURRENT_APP_ID = 'bgp'
|
||||
|
||||
export type AccessClaims = {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
exp?: number
|
||||
}
|
||||
|
||||
export type RuntimeAuthConfig = {
|
||||
required: boolean
|
||||
portalUrl: string
|
||||
}
|
||||
|
||||
let runtimeConfig: RuntimeAuthConfig | null = null
|
||||
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
|
||||
|
||||
function viteAuthEnabled(): boolean {
|
||||
return (
|
||||
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
|
||||
import.meta.env.VITE_AUTH_ENABLED === '1'
|
||||
)
|
||||
}
|
||||
|
||||
function vitePortalUrl(): string {
|
||||
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load auth mode from EvoBGP API (Docker-friendly). Falls back to VITE_* flags
|
||||
* when the endpoint isn't implemented (404) or the API is unreachable.
|
||||
*
|
||||
* Note: EvoBGP uses `/v1/...` (not `/api/v1/...`).
|
||||
*/
|
||||
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
if (runtimeConfigPromise) return runtimeConfigPromise
|
||||
|
||||
runtimeConfigPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch('/v1/auth/config', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
required?: boolean
|
||||
portal_url?: string
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: Boolean(data.required) || viteAuthEnabled(),
|
||||
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
|
||||
}
|
||||
return runtimeConfig
|
||||
}
|
||||
} catch {
|
||||
/* ignore — fall through to vite defaults */
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: viteAuthEnabled(),
|
||||
portalUrl: vitePortalUrl(),
|
||||
}
|
||||
return runtimeConfig
|
||||
})().finally(() => {
|
||||
runtimeConfigPromise = null
|
||||
})
|
||||
|
||||
return runtimeConfigPromise
|
||||
}
|
||||
|
||||
export function getAuthConfigSync(): RuntimeAuthConfig | null {
|
||||
return runtimeConfig
|
||||
}
|
||||
|
||||
export function getPortalToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setPortalToken(token: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearPortalToken(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function isAuthEnabled(): boolean {
|
||||
if (runtimeConfig) return runtimeConfig.required
|
||||
return viteAuthEnabled()
|
||||
}
|
||||
|
||||
export function authPortalUrl(): string {
|
||||
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
|
||||
return vitePortalUrl()
|
||||
}
|
||||
|
||||
export function isPortalHandoffCoolingDown(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const raw = window.sessionStorage.getItem(HANDOFF_AT_KEY)
|
||||
if (!raw) return false
|
||||
const at = Number(raw)
|
||||
if (!Number.isFinite(at)) return false
|
||||
return Date.now() - at < HANDOFF_COOLDOWN_MS
|
||||
}
|
||||
|
||||
export function markPortalHandoff(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.setItem(HANDOFF_KEY, '1')
|
||||
window.sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
|
||||
}
|
||||
|
||||
export function clearPortalHandoffFlag(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.removeItem(HANDOFF_KEY)
|
||||
}
|
||||
|
||||
/** Clear cooldown too — use on intentional logout so next login is allowed. */
|
||||
export function resetPortalHandoff(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.removeItem(HANDOFF_KEY)
|
||||
window.sessionStorage.removeItem(HANDOFF_AT_KEY)
|
||||
}
|
||||
|
||||
export function hasPortalHandoffFlag(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.sessionStorage.getItem(HANDOFF_KEY) === '1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
|
||||
* (clears local token) — prevents infinite SSO when API rejects JWT.
|
||||
*/
|
||||
export function redirectToPortalLogin(returnTo?: string): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (isPortalHandoffCoolingDown()) {
|
||||
clearPortalToken()
|
||||
return false
|
||||
}
|
||||
markPortalHandoff()
|
||||
const callback = returnTo ?? `${window.location.origin}/auth/callback`
|
||||
const url = new URL(authPortalUrl())
|
||||
url.searchParams.set('return_to', callback)
|
||||
window.location.assign(url.toString())
|
||||
return true
|
||||
}
|
||||
|
||||
/** End portal SSO session (refresh cookie + portal token). */
|
||||
export function redirectToPortalLogout(): void {
|
||||
clearPortalToken()
|
||||
resetPortalHandoff()
|
||||
if (typeof window === 'undefined') return
|
||||
window.location.assign(`${authPortalUrl()}/logout`)
|
||||
}
|
||||
|
||||
export function parseHashToken(hash: string): {
|
||||
accessToken: string | null
|
||||
expiresAt: string | null
|
||||
} {
|
||||
const raw = hash.startsWith('#') ? hash.slice(1) : hash
|
||||
const params = new URLSearchParams(raw)
|
||||
return {
|
||||
accessToken: params.get('access_token'),
|
||||
expiresAt: params.get('expires_at'),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeClaims(token: string): AccessClaims | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length < 2) return null
|
||||
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const payload = JSON.parse(json) as Record<string, unknown>
|
||||
return {
|
||||
sub: String(payload.sub ?? ''),
|
||||
email: String(payload.email ?? ''),
|
||||
name: String(payload.name ?? ''),
|
||||
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
|
||||
permissions: Array.isArray(payload.permissions)
|
||||
? payload.permissions.map(String)
|
||||
: [],
|
||||
is_admin: Boolean(payload.is_admin),
|
||||
iss: payload.iss ? String(payload.iss) : undefined,
|
||||
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaims(): AccessClaims | null {
|
||||
const token = getPortalToken()
|
||||
if (!token) return null
|
||||
const claims = decodeClaims(token)
|
||||
if (!claims) return null
|
||||
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||
clearPortalToken()
|
||||
return null
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true
|
||||
const parts = required.split(':')
|
||||
if (parts.length !== 3) return false
|
||||
const [app, section, action] = parts
|
||||
if (action === 'read') {
|
||||
return (
|
||||
granted.includes(`${app}:${section}:write`) ||
|
||||
granted.includes(`${app}:${section}:admin`)
|
||||
)
|
||||
}
|
||||
if (action === 'write') {
|
||||
return granted.includes(`${app}:${section}:admin`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Access-check: pass when portal auth is disabled or claim grants required. */
|
||||
export function can(required: string): boolean {
|
||||
if (!isAuthEnabled()) return true
|
||||
const claims = getClaims()
|
||||
if (!claims) return false
|
||||
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
||||
return hasPermission(claims.permissions, required)
|
||||
}
|
||||
|
||||
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||
export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||
return 'bgp:dashboard:read'
|
||||
}
|
||||
if (pathname.startsWith('/modules')) return 'bgp:modules:read'
|
||||
if (pathname.startsWith('/lookup')) return 'bgp:lookup:read'
|
||||
if (pathname.startsWith('/network')) return 'bgp:network:read'
|
||||
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
|
||||
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
|
||||
if (pathname.startsWith('/firewall')) return 'bgp:firewall:read'
|
||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
||||
return null
|
||||
}
|
||||
|
||||
const FALLBACK_PATH = '/dashboard'
|
||||
|
||||
/** First path in the sidebar the current user may open. */
|
||||
export function firstAllowedPath(): string {
|
||||
const candidates: readonly string[] = [
|
||||
'/dashboard',
|
||||
'/modules',
|
||||
'/lookup',
|
||||
'/network',
|
||||
'/directories',
|
||||
'/operations',
|
||||
'/firewall',
|
||||
'/schedule',
|
||||
'/monitoring',
|
||||
'/access',
|
||||
'/tenant-settings',
|
||||
'/settings',
|
||||
]
|
||||
for (const path of candidates) {
|
||||
const perm = permissionForPath(path)
|
||||
if (!perm || can(perm)) return path
|
||||
}
|
||||
return FALLBACK_PATH
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { ensureAuthConfig } from '@/lib/auth'
|
||||
import {
|
||||
DEFAULT_APP_SWITCHER_CONFIG,
|
||||
parseAppSwitcherConfig,
|
||||
type AppSwitcherConfig,
|
||||
} from '@/lib/app-switcher-config'
|
||||
|
||||
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
|
||||
|
||||
/** Portal contract shape: `{ menuLabel, apps: [{ id, name, url, icon, enabled }] }`. */
|
||||
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
|
||||
const { portalUrl } = await ensureAuthConfig()
|
||||
const base = portalUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/api/v1/app-switcher`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`app-switcher ${res.status}`)
|
||||
const raw = (await res.json()) as unknown
|
||||
return parseAppSwitcherConfig(JSON.stringify(raw))
|
||||
}
|
||||
|
||||
export function appSwitcherQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: appSwitcherQueryKey,
|
||||
queryFn: fetchPortalAppSwitcher,
|
||||
staleTime: 60_000,
|
||||
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
@@ -1,15 +1,69 @@
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||
|
||||
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import {
|
||||
can,
|
||||
ensureAuthConfig,
|
||||
firstAllowedPath,
|
||||
getClaims,
|
||||
getPortalToken,
|
||||
permissionForPath,
|
||||
redirectToPortalLogin,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Two-mode gate:
|
||||
* - VITE_AUTH_ENABLED / API `/v1/auth/config { required: true }`
|
||||
* → require auth-portal JWT (SSO) + section permission via `can()`.
|
||||
* - Off → keep legacy `evobgp_api_token` gate (redirect to /settings if empty).
|
||||
*/
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
beforeLoad: ({ location }) => {
|
||||
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
|
||||
beforeLoad: async ({ location }) => {
|
||||
const cfg = await ensureAuthConfig()
|
||||
|
||||
if (cfg.required) {
|
||||
const token = getPortalToken()
|
||||
const claims = getClaims()
|
||||
if (!token || !claims) {
|
||||
const ok = redirectToPortalLogin(
|
||||
`${window.location.origin}/auth/callback`,
|
||||
)
|
||||
if (!ok) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
if (!claims.apps.includes('bgp')) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
const perm = permissionForPath(location.pathname)
|
||||
if (perm && !can(perm)) {
|
||||
const fallback = firstAllowedPath()
|
||||
if (fallback !== location.pathname) {
|
||||
throw redirect({ to: fallback as '/dashboard' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Settings available without token — first-run onboarding (incl. `dev`).
|
||||
if (location.pathname === '/settings') return
|
||||
const raw =
|
||||
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
: null
|
||||
if (!raw || !normalizeApiToken(raw)) {
|
||||
throw redirect({ to: '/settings', search: { tab: 'connection', reason: 'token-required' } })
|
||||
throw redirect({
|
||||
to: '/settings',
|
||||
search: { tab: 'connection', reason: 'token-required' },
|
||||
})
|
||||
}
|
||||
},
|
||||
component: AuthLayout,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
import {
|
||||
authPortalUrl,
|
||||
clearPortalHandoffFlag,
|
||||
clearPortalToken,
|
||||
ensureAuthConfig,
|
||||
firstAllowedPath,
|
||||
getClaims,
|
||||
getPortalToken,
|
||||
parseHashToken,
|
||||
redirectToPortalLogin,
|
||||
setPortalToken,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* SSO callback — reads `#access_token=…&expires_at=…` returned by auth-portal,
|
||||
* stores the JWT, and drops the user on the first allowed page.
|
||||
*
|
||||
* If the hash is empty (direct visit / already logged in) the route re-runs the
|
||||
* portal handshake; the cool-down guard prevents redirect storms.
|
||||
*/
|
||||
export const Route = createFileRoute('/auth/callback')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
error: typeof search.error === 'string' ? search.error : undefined,
|
||||
}),
|
||||
beforeLoad: async ({ search }) => {
|
||||
await ensureAuthConfig()
|
||||
|
||||
if (search.error === 'sso_loop') return
|
||||
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const { accessToken } = parseHashToken(window.location.hash)
|
||||
if (accessToken) {
|
||||
setPortalToken(accessToken)
|
||||
clearPortalHandoffFlag()
|
||||
const claims = getClaims()
|
||||
if (!claims) {
|
||||
clearPortalToken()
|
||||
window.location.assign(authPortalUrl())
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
throw redirect({ to: firstAllowedPath() as '/dashboard' })
|
||||
}
|
||||
|
||||
if (getPortalToken() && getClaims()) {
|
||||
clearPortalHandoffFlag()
|
||||
throw redirect({ to: firstAllowedPath() as '/dashboard' })
|
||||
}
|
||||
|
||||
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
if (!ok) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
await new Promise(() => {})
|
||||
},
|
||||
component: AuthCallbackPage,
|
||||
})
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const { error } = Route.useSearch()
|
||||
if (error === 'sso_loop') {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<h1 className="text-lg font-semibold">Сессия не принята</h1>
|
||||
<p className="text-muted-foreground max-w-md text-sm">
|
||||
Повторный вход через auth-portal остановлен (защита от цикла редиректов).
|
||||
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
|
||||
Войдите заново на portal, затем откройте EvoBGP.
|
||||
</p>
|
||||
<a className="text-primary text-sm underline" href={authPortalUrl()}>
|
||||
Открыть Auth Portal
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
Vendored
+4
@@ -2,6 +2,10 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_SWITCHER?: string
|
||||
/** '1' | 'true' → require auth-portal JWT; иначе — локальный API-токен. */
|
||||
readonly VITE_AUTH_ENABLED?: string
|
||||
/** URL auth-portal (SSO). Пример: http://192.168.100.67:5175 или https://auth.shnt.top. */
|
||||
readonly VITE_AUTH_PORTAL_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user