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 <cursoragent@cursor.com>
232 lines
7.7 KiB
TypeScript
232 lines
7.7 KiB
TypeScript
import type {
|
||
AsEntriesResponse,
|
||
CdnSourcesResponse,
|
||
DomainEntriesResponse,
|
||
IpRangeEntriesResponse,
|
||
JobRow,
|
||
ModuleType,
|
||
RevisionPrefix,
|
||
RevisionPrefixesResponse,
|
||
} from '@/types/api'
|
||
|
||
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
||
|
||
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
|
||
export const DEV_API_TOKEN = 'dev'
|
||
|
||
export type Problem = {
|
||
type?: string
|
||
title?: string
|
||
status?: number
|
||
detail?: string
|
||
}
|
||
|
||
/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */
|
||
export function normalizeApiToken(raw: string): string {
|
||
let t = raw.trim()
|
||
if (/^bearer\s+/i.test(t)) {
|
||
t = t.replace(/^bearer\s+/i, '').trim()
|
||
}
|
||
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)
|
||
return normalized || null
|
||
}
|
||
|
||
export function setToken(token: string | null): void {
|
||
if (typeof window === 'undefined') return
|
||
if (!token) {
|
||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||
return
|
||
}
|
||
const normalized = normalizeApiToken(token)
|
||
if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized)
|
||
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||
}
|
||
|
||
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
|
||
const h = new Headers(init?.headers)
|
||
if (!h.has('Accept')) h.set('Accept', 'application/json')
|
||
const t = getToken()
|
||
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`)
|
||
if (extraHeaders) {
|
||
for (const [k, v] of Object.entries(extraHeaders)) {
|
||
if (!h.has(k)) h.set(k, v)
|
||
}
|
||
}
|
||
return h
|
||
}
|
||
|
||
/**
|
||
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
|
||
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
|
||
*/
|
||
function newIdempotencyKey(): string {
|
||
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
|
||
if (c?.randomUUID) return c.randomUUID()
|
||
if (c?.getRandomValues) {
|
||
const buf = new Uint8Array(16)
|
||
c.getRandomValues(buf)
|
||
buf[6] = (buf[6]! & 0x0f) | 0x40
|
||
buf[8] = (buf[8]! & 0x3f) | 0x80
|
||
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||
}
|
||
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
public readonly status: number,
|
||
message: string,
|
||
public readonly problem?: Problem,
|
||
) {
|
||
super(message)
|
||
}
|
||
}
|
||
|
||
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||
if (typeof window === 'undefined') throw new Error('API is only available in the browser')
|
||
return fetch(path, { ...init, headers: mergeHeaders(init) })
|
||
}
|
||
|
||
/** GET / DELETE без тела */
|
||
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await apiFetch(path, init)
|
||
return parseResponse<T>(res)
|
||
}
|
||
|
||
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
|
||
export async function apiMutate<T = void>(
|
||
path: string,
|
||
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||
body?: unknown,
|
||
opts?: { idempotent?: boolean },
|
||
): Promise<T> {
|
||
const headers: Record<string, string> = {}
|
||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||
if (opts?.idempotent !== false) {
|
||
headers['Idempotency-Key'] = newIdempotencyKey()
|
||
}
|
||
const res = await fetch(path, {
|
||
method,
|
||
headers: mergeHeaders({ headers }, headers),
|
||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||
})
|
||
return parseResponse<T>(res)
|
||
}
|
||
|
||
async function parseResponse<T>(res: Response): Promise<T> {
|
||
if (res.status === 204 || res.status === 205) return undefined as T
|
||
const text = await res.text()
|
||
if (!res.ok) {
|
||
let problem: Problem | undefined
|
||
let detail = `HTTP ${res.status}`
|
||
try {
|
||
problem = JSON.parse(text) as Problem
|
||
detail = problem.detail ?? problem.title ?? detail
|
||
} catch {
|
||
if (text) detail = text
|
||
}
|
||
throw new ApiError(res.status, detail, problem)
|
||
}
|
||
if (!text) return undefined as T
|
||
return JSON.parse(text) as T
|
||
}
|
||
|
||
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled'])
|
||
|
||
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
|
||
export async function waitForJob(
|
||
jobId: string,
|
||
opts?: { pollMs?: number; timeoutMs?: number },
|
||
): Promise<JobRow> {
|
||
const pollMs = opts?.pollMs ?? 400
|
||
const timeoutMs = opts?.timeoutMs ?? 120000
|
||
const deadline = Date.now() + timeoutMs
|
||
while (Date.now() < deadline) {
|
||
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`)
|
||
if (terminalJobStatuses.has(j.status)) return j
|
||
await new Promise((r) => setTimeout(r, pollMs))
|
||
}
|
||
throw new Error(`Таймаут ожидания задачи ${jobId}`)
|
||
}
|
||
|
||
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
|
||
const items: T[] = []
|
||
let cursor: string | null = null
|
||
while (true) {
|
||
const [basePath, rawQuery = ''] = path.split('?')
|
||
const query = new URLSearchParams(rawQuery)
|
||
if (!query.has('limit')) query.set('limit', String(limit))
|
||
if (cursor) query.set('cursor', cursor)
|
||
else query.delete('cursor')
|
||
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
|
||
`${basePath}?${query.toString()}`,
|
||
)
|
||
items.push(...(page.items ?? []))
|
||
if (!page.has_more || !page.next_cursor) break
|
||
cursor = page.next_cursor
|
||
}
|
||
return items
|
||
}
|
||
|
||
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
|
||
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`)
|
||
return items
|
||
}
|
||
|
||
export async function fetchRevisionPrefixesResponse(
|
||
revisionId: string,
|
||
cursor?: string | null,
|
||
limit = 500,
|
||
): Promise<RevisionPrefixesResponse> {
|
||
const query = new URLSearchParams({ limit: String(limit) })
|
||
if (cursor) query.set('cursor', cursor)
|
||
return apiJSON<RevisionPrefixesResponse>(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`)
|
||
}
|
||
|
||
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
|
||
if (moduleType === 'DOMAINS') {
|
||
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
|
||
`/v1/modules/${moduleId}/domain-entries`,
|
||
)
|
||
return { domains: entries }
|
||
}
|
||
if (moduleType === 'AS_PREFIXES') {
|
||
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
|
||
`/v1/modules/${moduleId}/as-entries`,
|
||
)
|
||
return { asns: entries }
|
||
}
|
||
if (moduleType === 'CDN_CIDRS') {
|
||
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
|
||
`/v1/modules/${moduleId}/cdn-sources`,
|
||
)
|
||
return { cdnSources: entries }
|
||
}
|
||
if (moduleType === 'IP_RANGES') {
|
||
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
|
||
`/v1/modules/${moduleId}/ip-range-entries`,
|
||
)
|
||
return { ipRanges: entries }
|
||
}
|
||
return {}
|
||
}
|