mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 16:07:29 +00:00
* feat(auth): add API key workspace Add persistent API key sessions and enforce admin/key authorization boundaries. Expose key-scoped quotas, trends, health, and paginated usage records through server-projected data. Share the application shell, system overview, and usage views between admin and key sessions, reorganize frontend domains around view ownership, and omit account, provider, and authentication details from the key workspace. BREAKING CHANGE: Browser authentication now uses /api/auth/* and cpr_session; existing browser sessions must sign in again. Validated frontend lint, type checking, production build, backend workspace tests, and live authenticated key views. Co-authored-by: Codex <noreply@openai.com> * feat(console): refine key workspace experience Unify role-aware console routes and use mode/role in authentication contracts. Reuse request state, stable pagination and performance projections across workspaces. Refine quota cards, usage panels, empty states and key-aware theme previews. Skip session restoration on login and retain form inputs until unmount. Keep the full health timeline grid visible without samples. Co-authored-by: Codex <noreply@openai.com> * feat(key-usage): replace shared workspace with focused usage page Preserve unified admin/key authentication, restore admin pages, and remove the previous client workspace implementation. Add a scoped usage page with metrics, trends, budgets, request logs, and section loading placeholders. Co-authored-by: Codex <noreply@openai.com> --------- Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
98 lines
2.8 KiB
TypeScript
98 lines
2.8 KiB
TypeScript
import type {
|
|
AxiosInstance,
|
|
AxiosRequestConfig,
|
|
} from 'axios'
|
|
|
|
import axios from 'axios'
|
|
import { toast } from '@/components/base/BaseToast'
|
|
import { API_BASE_URL, API_TIMEOUT_MS } from './constants'
|
|
import { ApiError, normalizeApiError, normalizeApiResponseError } from './error'
|
|
|
|
export { ApiError } from './error'
|
|
|
|
export interface RequestOptions {
|
|
// 静默只关闭全局提示,不吞掉异常,也不跳过会话失效处理。
|
|
silent?: boolean
|
|
signal?: AbortSignal
|
|
timeout?: number
|
|
}
|
|
|
|
type RequestConfig = AxiosRequestConfig & RequestOptions
|
|
|
|
const http: AxiosInstance = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
timeout: API_TIMEOUT_MS,
|
|
withCredentials: true,
|
|
})
|
|
|
|
// 会话失效是后端业务事实;登录凭据错误和 403 不清除已有会话。
|
|
const SESSION_REQUIRED = 40101
|
|
let unauthorizedHandled = false
|
|
let sessionGeneration = 0
|
|
let unauthorizedHandler: (() => void | Promise<void>) | undefined
|
|
|
|
export function setUnauthorizedHandler(handler: () => void | Promise<void>) {
|
|
unauthorizedHandler = handler
|
|
}
|
|
|
|
export function resetUnauthorizedHandling() {
|
|
sessionGeneration += 1
|
|
unauthorizedHandled = false
|
|
}
|
|
|
|
function handleUnauthorizedOnce() {
|
|
if (unauthorizedHandled || !unauthorizedHandler)
|
|
return
|
|
unauthorizedHandled = true
|
|
void Promise.resolve(unauthorizedHandler()).catch(() => {
|
|
unauthorizedHandled = false
|
|
})
|
|
}
|
|
|
|
function rejectRequest(error: ApiError, config: RequestConfig, generation: number) {
|
|
if (error.kind === 'cancelled' || config.signal?.aborted || generation !== sessionGeneration)
|
|
return Promise.reject(error)
|
|
|
|
const sessionExpired = error.status === 401 && error.code === SESSION_REQUIRED
|
|
const alreadyHandled = sessionExpired && unauthorizedHandled
|
|
if (sessionExpired)
|
|
handleUnauthorizedOnce()
|
|
if (!config.silent && !alreadyHandled)
|
|
toast.error(error.message)
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
interface ApiEnvelope {
|
|
code: number
|
|
message: string
|
|
data: unknown
|
|
}
|
|
|
|
function isApiEnvelope(value: unknown): value is ApiEnvelope {
|
|
return (
|
|
typeof value === 'object'
|
|
&& value !== null
|
|
&& 'data' in value
|
|
&& 'code' in value && typeof value.code === 'number'
|
|
&& 'message' in value && typeof value.message === 'string'
|
|
)
|
|
}
|
|
|
|
export default async function request<T = unknown>(config: RequestConfig): Promise<T> {
|
|
const generation = sessionGeneration
|
|
try {
|
|
const response = await http.request<unknown>(config)
|
|
const error = normalizeApiResponseError(response)
|
|
if (error)
|
|
throw error
|
|
return (isApiEnvelope(response.data) ? response.data.data : response.data) as T
|
|
}
|
|
catch (error) {
|
|
if (error instanceof ApiError)
|
|
return rejectRequest(error, config, generation)
|
|
if (axios.isAxiosError(error))
|
|
return rejectRequest(normalizeApiError(error), config, generation)
|
|
throw error
|
|
}
|
|
}
|