mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
1012 lines
32 KiB
TypeScript
1012 lines
32 KiB
TypeScript
import createClient from 'openapi-fetch'
|
|
import type { paths } from './generated/schema'
|
|
import type {
|
|
ChangePasswordRequest,
|
|
ApiToken,
|
|
CreateTokenRequest,
|
|
CreateTokenResponse,
|
|
MergeConfirmRequest,
|
|
LocalLoginRequest,
|
|
LocalRegisterRequest,
|
|
MergeInitiateRequest,
|
|
MergeInitiateResponse,
|
|
MergeVerifyRequest,
|
|
ReviewTask,
|
|
PromotionTask,
|
|
AuditLogItem,
|
|
SkillSummary,
|
|
SkillReport,
|
|
GovernanceSummary,
|
|
GovernanceInboxItem,
|
|
GovernanceActivityItem,
|
|
GovernanceNotification,
|
|
ReportDisposition,
|
|
AuthMethod,
|
|
OAuthProvider,
|
|
User,
|
|
ManagedNamespace,
|
|
Namespace,
|
|
CreateNamespaceRequest,
|
|
NamespaceMember,
|
|
NamespaceCandidateUser,
|
|
} from './types'
|
|
import { ApiError } from '@/shared/lib/api-error'
|
|
import i18n from '@/i18n/config'
|
|
|
|
/**
|
|
* Front-end API foundation for generated OpenAPI calls and hand-written convenience wrappers.
|
|
*
|
|
* This module centralizes runtime-config lookup, CSRF handling, localized request headers, envelope
|
|
* unwrapping, and exported API groups used throughout feature hooks.
|
|
*/
|
|
export { ApiError }
|
|
|
|
export const WEB_API_PREFIX = '/api/web'
|
|
|
|
type RuntimeConfig = {
|
|
apiBaseUrl?: string
|
|
appBaseUrl?: string
|
|
authDirectEnabled?: string
|
|
authDirectProvider?: string
|
|
authSessionBootstrapEnabled?: string
|
|
authSessionBootstrapProvider?: string
|
|
authSessionBootstrapAuto?: string
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__SKILLHUB_RUNTIME_CONFIG__?: RuntimeConfig
|
|
}
|
|
}
|
|
|
|
function getRuntimeConfig(): RuntimeConfig {
|
|
if (typeof window === 'undefined') {
|
|
return {}
|
|
}
|
|
return window.__SKILLHUB_RUNTIME_CONFIG__ ?? {}
|
|
}
|
|
|
|
function getApiBaseUrl(): string {
|
|
return getRuntimeConfig().apiBaseUrl ?? ''
|
|
}
|
|
|
|
function parseBooleanFlag(value: string | undefined): boolean {
|
|
if (!value) {
|
|
return false
|
|
}
|
|
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
|
|
}
|
|
|
|
const client = createClient<paths>({ baseUrl: getApiBaseUrl() })
|
|
|
|
function getCsrfToken(): string | null {
|
|
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
|
|
return match ? decodeURIComponent(match[1]) : null
|
|
}
|
|
|
|
function withRequestHeaders(headers?: HeadersInit): Headers {
|
|
const merged = new Headers(headers)
|
|
const language = i18n.resolvedLanguage?.trim()
|
|
if (language) {
|
|
merged.set('Accept-Language', language)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
function withCsrf(headers?: HeadersInit): HeadersInit {
|
|
const merged = withRequestHeaders(headers)
|
|
const csrfToken = getCsrfToken()
|
|
if (!csrfToken) {
|
|
return merged
|
|
}
|
|
|
|
merged.set('X-XSRF-TOKEN', csrfToken)
|
|
return merged
|
|
}
|
|
|
|
async function ensureCsrfHeaders(headers?: HeadersInit): Promise<HeadersInit> {
|
|
if (!getCsrfToken()) {
|
|
await client.GET('/api/v1/auth/providers', {
|
|
headers: withRequestHeaders(),
|
|
} as never)
|
|
}
|
|
return withCsrf(headers)
|
|
}
|
|
|
|
function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
|
|
return typeof value === 'object' && value !== null && 'code' in value && 'msg' in value && 'data' in value
|
|
}
|
|
|
|
function hasDataProperty<T>(value: unknown): value is { data: T } {
|
|
return typeof value === 'object' && value !== null && 'data' in value
|
|
}
|
|
|
|
async function unwrap<T>(promise: Promise<{ data?: T; error?: unknown; response: Response }>): Promise<T> {
|
|
// The backend returns a standard response envelope; normalize both success and error payloads
|
|
// here so feature hooks can work with plain values and one ApiError shape.
|
|
const { data, error, response } = await promise
|
|
const envelope = isApiEnvelope<T>(data) ? data : isApiEnvelope<T>(error) ? error : null
|
|
|
|
if (!response.ok) {
|
|
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg)
|
|
}
|
|
if (error) {
|
|
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg)
|
|
}
|
|
if (data === undefined) {
|
|
throw new ApiError(`HTTP ${response.status}`, response.status)
|
|
}
|
|
if (isApiEnvelope<T>(data)) {
|
|
if (data.code !== 0) {
|
|
throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg, data.msg)
|
|
}
|
|
return data.data
|
|
}
|
|
if (hasDataProperty<T>(data)) {
|
|
return data.data
|
|
}
|
|
return data
|
|
}
|
|
|
|
export function getCsrfHeaders(headers?: HeadersInit): HeadersInit {
|
|
return withCsrf(headers)
|
|
}
|
|
|
|
export type SessionBootstrapRuntimeConfig = {
|
|
enabled: boolean
|
|
provider?: string
|
|
auto: boolean
|
|
}
|
|
|
|
export type DirectAuthRuntimeConfig = {
|
|
enabled: boolean
|
|
provider?: string
|
|
}
|
|
|
|
export function getDirectAuthRuntimeConfig(): DirectAuthRuntimeConfig {
|
|
const config = getRuntimeConfig()
|
|
const provider = config.authDirectProvider?.trim()
|
|
return {
|
|
enabled: parseBooleanFlag(config.authDirectEnabled) && !!provider,
|
|
provider: provider || undefined,
|
|
}
|
|
}
|
|
|
|
export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfig {
|
|
const config = getRuntimeConfig()
|
|
const provider = config.authSessionBootstrapProvider?.trim()
|
|
return {
|
|
enabled: parseBooleanFlag(config.authSessionBootstrapEnabled) && !!provider,
|
|
provider: provider || undefined,
|
|
auto: parseBooleanFlag(config.authSessionBootstrapAuto),
|
|
}
|
|
}
|
|
|
|
type ApiEnvelope<T> = {
|
|
code: number
|
|
msg: string
|
|
data: T
|
|
timestamp: string
|
|
requestId: string
|
|
}
|
|
|
|
type RequestWithTimeout = RequestInit & {
|
|
timeoutMs?: number
|
|
}
|
|
|
|
function createRequestSignal(init?: RequestWithTimeout): { signal?: AbortSignal, cleanup: () => void } {
|
|
if (!init?.timeoutMs && !init?.signal) {
|
|
return { signal: init?.signal ?? undefined, cleanup: () => {} }
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const timeoutId = init?.timeoutMs ? window.setTimeout(() => controller.abort('timeout'), init.timeoutMs) : undefined
|
|
const abortListener = () => controller.abort()
|
|
|
|
if (init?.signal) {
|
|
if (init.signal.aborted) {
|
|
controller.abort()
|
|
} else {
|
|
init.signal.addEventListener('abort', abortListener, { once: true })
|
|
}
|
|
}
|
|
|
|
return {
|
|
signal: controller.signal,
|
|
cleanup: () => {
|
|
if (timeoutId !== undefined) {
|
|
window.clearTimeout(timeoutId)
|
|
}
|
|
init?.signal?.removeEventListener('abort', abortListener)
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestWithTimeout): Promise<T> {
|
|
const { signal, cleanup } = createRequestSignal(init)
|
|
let response: Response
|
|
try {
|
|
response = await fetch(withBaseUrl(input), {
|
|
...init,
|
|
signal,
|
|
headers: withRequestHeaders(init?.headers),
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
throw new ApiError('error.request.timeout', 408)
|
|
}
|
|
throw new ApiError('Network error', 0)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
|
|
let json: ApiEnvelope<T> | null = null
|
|
|
|
try {
|
|
json = (await response.json()) as ApiEnvelope<T>
|
|
} catch {
|
|
if (!response.ok) {
|
|
throw new ApiError(`HTTP ${response.status}`, response.status)
|
|
}
|
|
throw new ApiError('Invalid JSON response', response.status)
|
|
}
|
|
|
|
if (!response.ok || json.code !== 0) {
|
|
throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg, json.msg)
|
|
}
|
|
|
|
return json.data
|
|
}
|
|
|
|
export async function fetchText(input: RequestInfo | URL, init?: RequestInit): Promise<string> {
|
|
const response = await fetch(withBaseUrl(input), {
|
|
...init,
|
|
headers: withRequestHeaders(init?.headers),
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`)
|
|
}
|
|
return response.text()
|
|
}
|
|
|
|
function withBaseUrl(input: RequestInfo | URL): RequestInfo | URL {
|
|
const baseUrl = getApiBaseUrl()
|
|
if (!baseUrl || typeof input !== 'string' || !input.startsWith('/')) {
|
|
return input
|
|
}
|
|
return new URL(input, ensureTrailingSlash(baseUrl))
|
|
}
|
|
|
|
export function buildApiUrl(path: string): string {
|
|
const baseUrl = getApiBaseUrl()
|
|
if (!baseUrl) {
|
|
return path
|
|
}
|
|
return new URL(path, ensureTrailingSlash(baseUrl)).toString()
|
|
}
|
|
|
|
function ensureTrailingSlash(value: string): string {
|
|
return value.endsWith('/') ? value : `${value}/`
|
|
}
|
|
|
|
export async function getCurrentUser(): Promise<User | null> {
|
|
try {
|
|
const user = await unwrap<User>(client.GET('/api/v1/auth/me', {
|
|
headers: withRequestHeaders(),
|
|
} as never) as never)
|
|
return {
|
|
...user,
|
|
userId: user.userId ?? '',
|
|
displayName: user.displayName ?? '',
|
|
platformRoles: user.platformRoles ?? [],
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
return null
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export const authApi = {
|
|
getMe: getCurrentUser,
|
|
|
|
async getProviders(returnTo?: string): Promise<OAuthProvider[]> {
|
|
const providers = await unwrap<OAuthProvider[]>(client.GET('/api/v1/auth/providers', {
|
|
...(returnTo ? { query: { returnTo } } : {}),
|
|
headers: withRequestHeaders(),
|
|
} as never) as never)
|
|
return providers
|
|
.filter((provider) => provider.id && provider.name && provider.authorizationUrl)
|
|
.map((provider) => ({
|
|
...provider,
|
|
id: provider.id!,
|
|
name: provider.name!,
|
|
authorizationUrl: provider.authorizationUrl!,
|
|
}))
|
|
},
|
|
|
|
async getMethods(returnTo?: string): Promise<AuthMethod[]> {
|
|
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''
|
|
const methods = await fetchJson<AuthMethod[]>(`/api/v1/auth/methods${query}`)
|
|
return methods
|
|
.filter((method) => method.id && method.methodType && method.provider && method.displayName && method.actionUrl)
|
|
.map((method) => ({
|
|
...method,
|
|
id: method.id,
|
|
methodType: method.methodType,
|
|
provider: method.provider,
|
|
displayName: method.displayName,
|
|
actionUrl: method.actionUrl,
|
|
}))
|
|
},
|
|
|
|
async localLogin(request: LocalLoginRequest): Promise<User> {
|
|
return fetchJson<User>('/api/v1/auth/local/login', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async localRegister(request: LocalRegisterRequest): Promise<User> {
|
|
return fetchJson<User>('/api/v1/auth/local/register', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async changePassword(request: ChangePasswordRequest): Promise<void> {
|
|
await fetchJson<void>('/api/v1/auth/local/change-password', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async logout(): Promise<void> {
|
|
const response = await fetch('/api/v1/auth/logout', {
|
|
method: 'POST',
|
|
headers: withCsrf(),
|
|
})
|
|
if (response.status !== 200 && response.status !== 204) {
|
|
throw new Error(`HTTP ${response.status}`)
|
|
}
|
|
},
|
|
|
|
async bootstrapSession(provider: string): Promise<User> {
|
|
return fetchJson<User>('/api/v1/auth/session/bootstrap', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ provider }),
|
|
})
|
|
},
|
|
|
|
async directLogin(provider: string, request: LocalLoginRequest): Promise<User> {
|
|
return fetchJson<User>('/api/v1/auth/direct/login', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
provider,
|
|
username: request.username,
|
|
password: request.password,
|
|
}),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const accountApi = {
|
|
async initiateMerge(request: MergeInitiateRequest): Promise<MergeInitiateResponse> {
|
|
return fetchJson<MergeInitiateResponse>('/api/v1/account/merge/initiate', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async verifyMerge(request: MergeVerifyRequest): Promise<void> {
|
|
await fetchJson<void>('/api/v1/account/merge/verify', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async confirmMerge(request: MergeConfirmRequest): Promise<void> {
|
|
await fetchJson<void>('/api/v1/account/merge/confirm', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const skillLifecycleApi = {
|
|
async archiveSkill(namespace: string, slug: string, reason?: string): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/archive`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
})
|
|
},
|
|
|
|
async unarchiveSkill(namespace: string, slug: string): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/unarchive`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async deleteVersion(namespace: string, slug: string, version: string): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}`, {
|
|
method: 'DELETE',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async withdrawReview(namespace: string, slug: string, version: string): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}/withdraw-review`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}/rerelease`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ targetVersion }),
|
|
})
|
|
},
|
|
}
|
|
|
|
function normalizeNamespaceSlug(namespace: string): string {
|
|
return namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
}
|
|
|
|
export const namespaceApi = {
|
|
async create(request: CreateNamespaceRequest): Promise<Namespace> {
|
|
const namespace = await unwrap<Namespace>(client.POST('/api/v1/namespaces', {
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: {
|
|
slug: normalizeNamespaceSlug(request.slug),
|
|
displayName: request.displayName.trim(),
|
|
description: request.description?.trim() || undefined,
|
|
},
|
|
} as never) as never)
|
|
return namespace
|
|
},
|
|
|
|
async listMine(): Promise<ManagedNamespace[]> {
|
|
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
|
|
},
|
|
|
|
async getDetail(slug: string): Promise<Namespace> {
|
|
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`)
|
|
},
|
|
|
|
async freeze(slug: string): Promise<Namespace> {
|
|
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/freeze`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async unfreeze(slug: string): Promise<Namespace> {
|
|
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async archive(slug: string, reason?: string): Promise<Namespace> {
|
|
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/archive`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
})
|
|
},
|
|
|
|
async restore(slug: string): Promise<Namespace> {
|
|
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/restore`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async listMembers(slug: string): Promise<NamespaceMember[]> {
|
|
const page = await fetchJson<{ items: NamespaceMember[] }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`)
|
|
return page.items
|
|
},
|
|
|
|
async searchMemberCandidates(slug: string, search: string, size = 10): Promise<NamespaceCandidateUser[]> {
|
|
const query = new URLSearchParams({
|
|
search: search.trim(),
|
|
size: String(size),
|
|
})
|
|
return fetchJson<NamespaceCandidateUser[]>(
|
|
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${query.toString()}`,
|
|
)
|
|
},
|
|
|
|
async addMember(slug: string, request: { userId: string; role: string }): Promise<NamespaceMember> {
|
|
return fetchJson<NamespaceMember>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
userId: request.userId.trim(),
|
|
role: request.role,
|
|
}),
|
|
})
|
|
},
|
|
|
|
async updateMemberRole(slug: string, userId: string, role: string): Promise<NamespaceMember> {
|
|
return fetchJson<NamespaceMember>(
|
|
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}/role`,
|
|
{
|
|
method: 'PUT',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ role }),
|
|
},
|
|
)
|
|
},
|
|
|
|
async removeMember(slug: string, userId: string): Promise<void> {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}`, {
|
|
method: 'DELETE',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const tokenApi = {
|
|
async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> {
|
|
const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', {
|
|
params: {
|
|
query: {
|
|
page: params?.page ?? 0,
|
|
size: params?.size ?? 10,
|
|
},
|
|
},
|
|
headers: withRequestHeaders(),
|
|
} as never) as never)
|
|
return {
|
|
...page,
|
|
items: page.items
|
|
.filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt)
|
|
.map((token) => ({
|
|
...token,
|
|
id: token.id!,
|
|
name: token.name!,
|
|
tokenPrefix: token.tokenPrefix!,
|
|
createdAt: token.createdAt!,
|
|
})),
|
|
}
|
|
},
|
|
|
|
async createToken(request: CreateTokenRequest): Promise<CreateTokenResponse> {
|
|
const token = await unwrap<CreateTokenResponse>(client.POST('/api/v1/tokens', {
|
|
headers: withCsrf({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: request,
|
|
}) as never)
|
|
if (!token.token || token.id === undefined || !token.name || !token.tokenPrefix || !token.createdAt) {
|
|
throw new Error('Invalid token creation response')
|
|
}
|
|
return {
|
|
...token,
|
|
token: token.token,
|
|
id: token.id,
|
|
name: token.name,
|
|
tokenPrefix: token.tokenPrefix,
|
|
createdAt: token.createdAt,
|
|
}
|
|
},
|
|
|
|
async updateTokenExpiration(tokenId: number, expiresAt?: string): Promise<ApiToken> {
|
|
return fetchJson<ApiToken>(`/api/v1/tokens/${tokenId}/expiration`, {
|
|
method: 'PUT',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ expiresAt: expiresAt ?? '' }),
|
|
})
|
|
},
|
|
|
|
async deleteToken(tokenId: number): Promise<void> {
|
|
const { error, response } = await client.DELETE('/api/v1/tokens/{id}', {
|
|
params: {
|
|
path: {
|
|
id: tokenId,
|
|
},
|
|
},
|
|
headers: withCsrf(),
|
|
} as never)
|
|
|
|
if (response.status === 204) {
|
|
return
|
|
}
|
|
|
|
const envelope = (error && isApiEnvelope<void>(error) ? error : null) as { msg?: string } | null
|
|
if (!response.ok || error) {
|
|
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg)
|
|
}
|
|
},
|
|
}
|
|
|
|
export const reviewApi = {
|
|
async list(params: { status: string; namespaceId?: number; page?: number; size?: number }) {
|
|
const searchParams = new URLSearchParams()
|
|
searchParams.set('status', params.status)
|
|
if (params.namespaceId !== undefined) {
|
|
searchParams.set('namespaceId', String(params.namespaceId))
|
|
}
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
return fetchJson<{ items: ReviewTask[]; total: number; page: number; size: number }>(
|
|
`${WEB_API_PREFIX}/reviews?${searchParams.toString()}`,
|
|
)
|
|
},
|
|
|
|
async get(id: number): Promise<ReviewTask> {
|
|
return fetchJson<ReviewTask>(`${WEB_API_PREFIX}/reviews/${id}`)
|
|
},
|
|
|
|
async approve(id: number, comment?: string): Promise<void> {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/reviews/${id}/approve`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ comment }),
|
|
})
|
|
},
|
|
|
|
async reject(id: number, comment: string): Promise<void> {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/reviews/${id}/reject`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ comment }),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const promotionApi = {
|
|
async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise<void> {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/promotions`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async list(params: { status?: string; page?: number; size?: number }) {
|
|
const searchParams = new URLSearchParams()
|
|
searchParams.set('status', params.status ?? 'PENDING')
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>(
|
|
`${WEB_API_PREFIX}/promotions?${searchParams.toString()}`,
|
|
)
|
|
},
|
|
|
|
async get(id: number): Promise<PromotionTask> {
|
|
return fetchJson<PromotionTask>(`${WEB_API_PREFIX}/promotions/${id}`)
|
|
},
|
|
|
|
async approve(id: number, comment?: string): Promise<void> {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/promotions/${id}/approve`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ comment }),
|
|
})
|
|
},
|
|
|
|
async reject(id: number, comment?: string): Promise<void> {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/promotions/${id}/reject`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ comment }),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const reportApi = {
|
|
async submitSkillReport(namespace: string, slug: string, request: { reason: string; details?: string }): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/reports`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async listSkillReports(params: { status?: string; page?: number; size?: number }) {
|
|
const searchParams = new URLSearchParams()
|
|
searchParams.set('status', params.status ?? 'PENDING')
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
return fetchJson<{ items: SkillReport[]; total: number; page: number; size: number }>(
|
|
`/api/v1/admin/skill-reports?${searchParams.toString()}`,
|
|
)
|
|
},
|
|
|
|
async resolveSkillReport(id: number, comment?: string, disposition: ReportDisposition = 'RESOLVE_ONLY'): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/resolve`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ comment, disposition }),
|
|
})
|
|
},
|
|
|
|
async dismissSkillReport(id: number, comment?: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/skill-reports/${id}/dismiss`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ comment }),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const governanceApi = {
|
|
async getSummary(): Promise<GovernanceSummary> {
|
|
return fetchJson<GovernanceSummary>(`${WEB_API_PREFIX}/governance/summary`)
|
|
},
|
|
|
|
async getInbox(params: { type?: string; page?: number; size?: number }) {
|
|
const searchParams = new URLSearchParams()
|
|
if (params.type) searchParams.set('type', params.type)
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
return fetchJson<{ items: GovernanceInboxItem[]; total: number; page: number; size: number }>(
|
|
`${WEB_API_PREFIX}/governance/inbox?${searchParams.toString()}`,
|
|
)
|
|
},
|
|
|
|
async getActivity(params: { page?: number; size?: number }) {
|
|
const searchParams = new URLSearchParams()
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
return fetchJson<{ items: GovernanceActivityItem[]; total: number; page: number; size: number }>(
|
|
`${WEB_API_PREFIX}/governance/activity?${searchParams.toString()}`,
|
|
)
|
|
},
|
|
|
|
async getNotifications(): Promise<GovernanceNotification[]> {
|
|
return fetchJson<GovernanceNotification[]>(`${WEB_API_PREFIX}/governance/notifications`)
|
|
},
|
|
|
|
async markNotificationRead(id: number): Promise<GovernanceNotification> {
|
|
return fetchJson<GovernanceNotification>(`${WEB_API_PREFIX}/governance/notifications/${id}/read`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async rebuildSearchIndex(): Promise<void> {
|
|
await fetchJson<void>('/api/v1/admin/search/rebuild', {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const meApi = {
|
|
async getSkills(params?: { page?: number; size?: number }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
|
|
const searchParams = new URLSearchParams()
|
|
searchParams.set('page', String(params?.page ?? 0))
|
|
searchParams.set('size', String(params?.size ?? 10))
|
|
return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`)
|
|
},
|
|
|
|
async getStarsPage(params?: { page?: number; size?: number }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> {
|
|
const searchParams = new URLSearchParams()
|
|
searchParams.set('page', String(params?.page ?? 0))
|
|
searchParams.set('size', String(params?.size ?? 12))
|
|
return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/stars?${searchParams.toString()}`)
|
|
},
|
|
|
|
async getStars(): Promise<SkillSummary[]> {
|
|
const items: SkillSummary[] = []
|
|
let page = 0
|
|
const size = 100
|
|
let hasMore = true
|
|
|
|
while (hasMore) {
|
|
const response = await meApi.getStarsPage({ page, size })
|
|
items.push(...response.items)
|
|
|
|
hasMore = (page + 1) * response.size < response.total && response.items.length > 0
|
|
page += 1
|
|
}
|
|
|
|
return items
|
|
},
|
|
}
|
|
|
|
export const profileApi = {
|
|
async updateProfile(request: { displayName: string }): Promise<{ status: string }> {
|
|
return fetchJson<{ status: string }>('/api/v1/user/profile', {
|
|
method: 'PATCH',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const adminApi = {
|
|
async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) {
|
|
const searchParams = new URLSearchParams()
|
|
if (params.search) searchParams.set('search', params.search)
|
|
if (params.status) searchParams.set('status', params.status)
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
const response = await fetchJson<{
|
|
items: Array<{
|
|
id: string
|
|
username: string
|
|
email?: string
|
|
platformRoles?: string[]
|
|
status: string
|
|
createdAt: string
|
|
}>
|
|
total: number
|
|
page: number
|
|
size: number
|
|
}>(
|
|
`/api/v1/admin/users?${searchParams.toString()}`,
|
|
)
|
|
return {
|
|
...response,
|
|
items: response.items
|
|
.filter((user) => user.id && user.username && user.status && user.createdAt)
|
|
.map((user) => ({
|
|
userId: user.id,
|
|
username: user.username,
|
|
email: user.email,
|
|
platformRoles: user.platformRoles ?? [],
|
|
status: user.status,
|
|
createdAt: user.createdAt,
|
|
})),
|
|
}
|
|
},
|
|
|
|
async updateUserRole(userId: string, role: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/users/${userId}/role`, {
|
|
method: 'PUT',
|
|
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ role }),
|
|
})
|
|
},
|
|
|
|
async updateUserStatus(userId: string, status: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/users/${userId}/status`, {
|
|
method: 'PUT',
|
|
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ status }),
|
|
})
|
|
},
|
|
|
|
async approveUser(userId: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/users/${userId}/approve`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async disableUser(userId: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/users/${userId}/disable`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async enableUser(userId: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/users/${userId}/enable`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async getAuditLogs(params: {
|
|
action?: string
|
|
userId?: string
|
|
requestId?: string
|
|
ipAddress?: string
|
|
resourceType?: string
|
|
resourceId?: string
|
|
startTime?: string
|
|
endTime?: string
|
|
page?: number
|
|
size?: number
|
|
}) {
|
|
const searchParams = new URLSearchParams()
|
|
if (params.action) searchParams.set('action', params.action)
|
|
if (params.userId) searchParams.set('userId', params.userId)
|
|
if (params.requestId) searchParams.set('requestId', params.requestId)
|
|
if (params.ipAddress) searchParams.set('ipAddress', params.ipAddress)
|
|
if (params.resourceType) searchParams.set('resourceType', params.resourceType)
|
|
if (params.resourceId) searchParams.set('resourceId', params.resourceId)
|
|
if (params.startTime) searchParams.set('startTime', params.startTime)
|
|
if (params.endTime) searchParams.set('endTime', params.endTime)
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
return fetchJson<{ items: AuditLogItem[]; total: number; page: number; size: number }>(
|
|
`/api/v1/admin/audit-logs?${searchParams.toString()}`,
|
|
)
|
|
},
|
|
|
|
async hideSkill(skillId: number, reason?: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/skills/${skillId}/hide`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ reason }),
|
|
})
|
|
},
|
|
|
|
async unhideSkill(skillId: number): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/skills/${skillId}/unhide`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async yankVersion(versionId: number, reason?: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/skills/versions/${versionId}/yank`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ reason }),
|
|
})
|
|
},
|
|
}
|