CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m6s
Refactored the settings components to unify navigation between UI and BIRD settings. Updated tab structures to streamline access and improve user experience. Adjusted routing and search parameters to reflect the new tab organization, ensuring a cohesive interface. Removed legacy tenant settings references and enhanced the settings page layout for clarity and usability.
382 lines
11 KiB
TypeScript
382 lines
11 KiB
TypeScript
/**
|
|
* 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
|
|
if (claims.is_admin) return true
|
|
return hasPermission(claims.permissions, required)
|
|
}
|
|
|
|
/**
|
|
* Whether /v1/auth/session may manage API keys (`bgp:access:admin`).
|
|
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key operator.
|
|
*/
|
|
export function sessionCanManageApiKeys(session: {
|
|
role?: string
|
|
kind?: string
|
|
is_admin?: boolean
|
|
permissions?: readonly string[]
|
|
} | null | undefined): boolean {
|
|
if (!session) return false
|
|
const jwtPath =
|
|
session.kind === 'jwt' ||
|
|
session.is_admin === true ||
|
|
(session.permissions?.length ?? 0) > 0
|
|
if (jwtPath) {
|
|
return (
|
|
session.is_admin === true ||
|
|
hasPermission(session.permissions ?? [], 'bgp:access:admin')
|
|
)
|
|
}
|
|
return session.role === 'operator'
|
|
}
|
|
|
|
/**
|
|
* Whether session may create/update module entries (`bgp:modules:write`).
|
|
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
|
|
*/
|
|
export function sessionCanWriteModules(session: {
|
|
role?: string
|
|
kind?: string
|
|
is_admin?: boolean
|
|
permissions?: readonly string[]
|
|
} | null | undefined): boolean {
|
|
if (!session) return false
|
|
const jwtPath =
|
|
session.kind === 'jwt' ||
|
|
session.is_admin === true ||
|
|
(session.permissions?.length ?? 0) > 0
|
|
if (jwtPath) {
|
|
return (
|
|
session.is_admin === true ||
|
|
hasPermission(session.permissions ?? [], 'bgp:modules:write')
|
|
)
|
|
}
|
|
const role = (session.role ?? '').toLowerCase()
|
|
return role === 'editor' || role === 'operator'
|
|
}
|
|
|
|
/**
|
|
* Whether session may create/update directories (`bgp:directories:write`).
|
|
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
|
|
*/
|
|
export function sessionCanWriteDirectories(session: {
|
|
role?: string
|
|
kind?: string
|
|
is_admin?: boolean
|
|
permissions?: readonly string[]
|
|
} | null | undefined): boolean {
|
|
if (!session) return false
|
|
const jwtPath =
|
|
session.kind === 'jwt' ||
|
|
session.is_admin === true ||
|
|
(session.permissions?.length ?? 0) > 0
|
|
if (jwtPath) {
|
|
return (
|
|
session.is_admin === true ||
|
|
hasPermission(session.permissions ?? [], 'bgp:directories:write')
|
|
)
|
|
}
|
|
const role = (session.role ?? '').toLowerCase()
|
|
return role === 'editor' || role === 'operator'
|
|
}
|
|
|
|
/** 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('/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') || pathname.startsWith('/settings')) {
|
|
return canOpenSettings() ? null : 'bgp:settings:read'
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */
|
|
export function canOpenSettings(): boolean {
|
|
return can('bgp:settings:read') || can('bgp:tenant_settings:admin')
|
|
}
|
|
|
|
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',
|
|
'/schedule',
|
|
'/monitoring',
|
|
'/access',
|
|
'/settings',
|
|
]
|
|
for (const path of candidates) {
|
|
const perm = permissionForPath(path)
|
|
if (!perm || can(perm)) return path
|
|
}
|
|
return FALLBACK_PATH
|
|
}
|