skillhub/web/src/api/client.ts

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 }),
})
},
}