mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
# Conflicts: # server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java # web/src/pages/dashboard/publish.tsx
1412 lines
47 KiB
TypeScript
1412 lines
47 KiB
TypeScript
import createClient from 'openapi-fetch'
|
|
import { BASE_PATH } from '@/shared/lib/base-path'
|
|
import type { paths } from './generated/schema'
|
|
import type {
|
|
ChangePasswordRequest,
|
|
PasswordResetConfirmRequest,
|
|
PasswordResetRequest,
|
|
ApiToken,
|
|
CreateTokenRequest,
|
|
CreateTokenResponse,
|
|
MergeConfirmRequest,
|
|
LocalLoginRequest,
|
|
LocalRegisterRequest,
|
|
MergeInitiateRequest,
|
|
MergeInitiateResponse,
|
|
MergeVerifyRequest,
|
|
ReviewSkillDetail,
|
|
ReviewTask,
|
|
PromotionSortBy,
|
|
PromotionSortDirection,
|
|
PromotionStatus,
|
|
PromotionTask,
|
|
AuditLogItem,
|
|
SkillSummary,
|
|
SkillReport,
|
|
GovernanceSummary,
|
|
GovernanceInboxItem,
|
|
GovernanceActivityItem,
|
|
GovernanceNotification,
|
|
PagedResponse,
|
|
ReportDisposition,
|
|
AuthMethod,
|
|
OAuthProvider,
|
|
User,
|
|
ManagedNamespace,
|
|
Namespace,
|
|
CreateNamespaceRequest,
|
|
NamespaceMember,
|
|
NamespaceCandidateUser,
|
|
NotificationItem,
|
|
NotificationPreferenceItem,
|
|
NotificationUnreadCount,
|
|
SkillDeleteResult,
|
|
AdminLabelInput,
|
|
LabelDefinition,
|
|
LabelItem,
|
|
BatchMemberResponse,
|
|
} from './types'
|
|
import { ApiError } from '@/shared/lib/api-error'
|
|
import i18n from '@/i18n/config'
|
|
|
|
type OperationQuery<Operation> = Operation extends { parameters: { query?: infer Query } } ? NonNullable<Query> : never
|
|
type OperationData<Operation> = Operation extends {
|
|
responses: {
|
|
200: {
|
|
content: {
|
|
'*/*': infer Envelope
|
|
}
|
|
}
|
|
}
|
|
} ? Envelope extends { data?: infer Data } ? NonNullable<Data> : never : never
|
|
|
|
type ListMyNamespacesPageOperation = paths['/api/web/me/namespaces/page']['get']
|
|
|
|
export type MyNamespacePageParams = OperationQuery<ListMyNamespacesPageOperation>
|
|
export type MyNamespacePageResponse = OperationData<ListMyNamespacesPageOperation> & PagedResponse<ManagedNamespace>
|
|
|
|
/**
|
|
* 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 {
|
|
const configured = getRuntimeConfig().apiBaseUrl
|
|
if (configured) {
|
|
return configured
|
|
}
|
|
// Default the API prefix to the deployment base path so that setting only
|
|
// SKILLHUB_WEB_BASE_PATH (e.g. /skillhub/) still routes API calls to
|
|
// /skillhub/api/... instead of /api/... behind a sub-path-only reverse proxy.
|
|
// BASE_PATH is import.meta.env.BASE_URL (substituted at container start) and
|
|
// always ends with '/'; drop it so requests are /skillhub/api, not /skillhub//api.
|
|
return BASE_PATH === '/' ? '' : BASE_PATH.replace(/\/+$/, '')
|
|
}
|
|
|
|
export function getAppBaseUrl(): string {
|
|
return getRuntimeConfig().appBaseUrl ?? ''
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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('apiError.networkError', 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 prependApiBaseUrl(baseUrl, input)
|
|
}
|
|
|
|
export function buildApiUrl(path: string): string {
|
|
const baseUrl = getApiBaseUrl()
|
|
if (!baseUrl) {
|
|
return path
|
|
}
|
|
return prependApiBaseUrl(baseUrl, path)
|
|
}
|
|
|
|
function prependApiBaseUrl(baseUrl: string, path: string): string {
|
|
const normalizedBaseUrl = trimTrailingSlash(baseUrl)
|
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
return `${normalizedBaseUrl}${normalizedPath}`
|
|
}
|
|
|
|
function trimTrailingSlash(value: string): string {
|
|
if (value.length > 1 && value.endsWith('/')) {
|
|
return value.slice(0, -1)
|
|
}
|
|
return value
|
|
}
|
|
|
|
export async function getCurrentUser(): Promise<User | null> {
|
|
try {
|
|
const user = await fetchJson<User>('/api/v1/auth/me')
|
|
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 params = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''
|
|
const providers = await fetchJson<OAuthProvider[]>(`/api/v1/auth/providers${params}`)
|
|
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 requestPasswordReset(request: PasswordResetRequest): Promise<void> {
|
|
await fetchJson<void>('/api/v1/auth/local/password-reset/request', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async confirmPasswordReset(request: PasswordResetConfirmRequest): Promise<void> {
|
|
await fetchJson<void>('/api/v1/auth/local/password-reset/confirm', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
},
|
|
|
|
async logout(): Promise<void> {
|
|
const response = await fetch(buildApiUrl('/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}/${encodeURIComponent(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}/${encodeURIComponent(slug)}/unarchive`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async deleteSkill(namespace: string, slug: string, ownerId?: string): Promise<SkillDeleteResult> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
const params = ownerId ? `?ownerId=${encodeURIComponent(ownerId)}` : ''
|
|
return fetchJson<SkillDeleteResult>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}${params}`, {
|
|
method: 'DELETE',
|
|
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}/${encodeURIComponent(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}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/withdraw-review`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ targetVersion, confirmWarnings }),
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Submit an UPLOADED version for review.
|
|
* Transitions version status from UPLOADED to PENDING_REVIEW.
|
|
*/
|
|
async submitForReview(namespace: string, slug: string, version: string, targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/submit-review`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ version, targetVisibility }),
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Confirm publish for a PRIVATE skill version.
|
|
* Transitions version status from UPLOADED to PUBLISHED without review.
|
|
*/
|
|
async confirmPublish(namespace: string, slug: string, version: string): Promise<void> {
|
|
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/confirm-publish`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ version }),
|
|
})
|
|
},
|
|
}
|
|
|
|
function normalizeNamespaceSlug(namespace: string): string {
|
|
return namespace.startsWith('@') ? namespace.slice(1) : namespace
|
|
}
|
|
|
|
export const labelApi = {
|
|
async listVisible(): Promise<LabelItem[]> {
|
|
return fetchJson<LabelItem[]>(`${WEB_API_PREFIX}/labels`)
|
|
},
|
|
|
|
async listSkillLabels(namespace: string, slug: string): Promise<LabelItem[]> {
|
|
const cleanNamespace = normalizeNamespaceSlug(namespace)
|
|
return fetchJson<LabelItem[]>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/labels`)
|
|
},
|
|
|
|
async attachSkillLabel(namespace: string, slug: string, labelSlug: string): Promise<LabelItem> {
|
|
const cleanNamespace = normalizeNamespaceSlug(namespace)
|
|
return fetchJson<LabelItem>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/labels/${encodeURIComponent(labelSlug)}`, {
|
|
method: 'PUT',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async detachSkillLabel(namespace: string, slug: string, labelSlug: string): Promise<void> {
|
|
const cleanNamespace = normalizeNamespaceSlug(namespace)
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/labels/${encodeURIComponent(labelSlug)}`, {
|
|
method: 'DELETE',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async listAdminDefinitions(): Promise<LabelDefinition[]> {
|
|
return fetchJson<LabelDefinition[]>('/api/v1/admin/labels')
|
|
},
|
|
|
|
async createAdminDefinition(request: AdminLabelInput): Promise<LabelDefinition> {
|
|
return fetchJson<LabelDefinition>('/api/v1/admin/labels', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
slug: request.slug.trim(),
|
|
type: request.type,
|
|
visibleInFilter: request.visibleInFilter,
|
|
sortOrder: request.sortOrder,
|
|
translations: request.translations.map((translation) => ({
|
|
locale: translation.locale.trim(),
|
|
displayName: translation.displayName.trim(),
|
|
})),
|
|
}),
|
|
})
|
|
},
|
|
|
|
async updateAdminDefinition(slug: string, request: Omit<AdminLabelInput, 'slug'>): Promise<LabelDefinition> {
|
|
return fetchJson<LabelDefinition>(`/api/v1/admin/labels/${encodeURIComponent(slug)}`, {
|
|
method: 'PUT',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
type: request.type,
|
|
visibleInFilter: request.visibleInFilter,
|
|
sortOrder: request.sortOrder,
|
|
translations: request.translations.map((translation) => ({
|
|
locale: translation.locale.trim(),
|
|
displayName: translation.displayName.trim(),
|
|
})),
|
|
}),
|
|
})
|
|
},
|
|
|
|
async deleteAdminDefinition(slug: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/labels/${encodeURIComponent(slug)}`, {
|
|
method: 'DELETE',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async updateAdminSortOrder(items: Array<{ slug: string; sortOrder: number }>): Promise<LabelDefinition[]> {
|
|
return fetchJson<LabelDefinition[]>('/api/v1/admin/labels/sort-order', {
|
|
method: 'PUT',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ items }),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const namespaceApi = {
|
|
async create(request: CreateNamespaceRequest): Promise<Namespace> {
|
|
const namespace = await fetchJson<Namespace>('/api/v1/namespaces', {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
slug: normalizeNamespaceSlug(request.slug),
|
|
displayName: request.displayName.trim(),
|
|
description: request.description?.trim() || undefined,
|
|
}),
|
|
})
|
|
return namespace
|
|
},
|
|
|
|
async listMine(): Promise<ManagedNamespace[]> {
|
|
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
|
|
},
|
|
|
|
async listMinePage(params: MyNamespacePageParams = {}): Promise<MyNamespacePageResponse> {
|
|
const page = params.page ?? 0
|
|
const size = params.size ?? 20
|
|
const query = new URLSearchParams({ page: String(page), size: String(size) })
|
|
params.sort?.forEach((sort) => query.append('sort', sort))
|
|
if (params.status) {
|
|
query.set('status', params.status)
|
|
}
|
|
if (params.type) {
|
|
query.set('type', params.type)
|
|
}
|
|
if (params.q?.trim()) {
|
|
query.set('q', params.q.trim())
|
|
}
|
|
if (params.slug?.trim()) {
|
|
query.set('slug', normalizeNamespaceSlug(params.slug))
|
|
}
|
|
params.roles?.forEach((role) => query.append('roles', role))
|
|
return fetchJson<MyNamespacePageResponse>(`${WEB_API_PREFIX}/me/namespaces/page?${query.toString()}`)
|
|
},
|
|
|
|
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 delete(slug: string): Promise<void> {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`, {
|
|
method: 'DELETE',
|
|
headers: await ensureCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async listMembers(slug: string, params?: { page?: number; size?: number }): Promise<PagedResponse<NamespaceMember>> {
|
|
const queryPage = params?.page ?? 0
|
|
const querySize = params?.size ?? 20
|
|
return fetchJson<PagedResponse<NamespaceMember>>(
|
|
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members?page=${queryPage}&size=${querySize}`,
|
|
)
|
|
},
|
|
|
|
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 batchAddMembers(slug: string, members: Array<{ userId: string; role: string }>): Promise<BatchMemberResponse> {
|
|
return fetchJson<BatchMemberResponse>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/batch`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ members }),
|
|
})
|
|
},
|
|
|
|
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(),
|
|
})
|
|
},
|
|
|
|
async update(slug: string, request: { displayName?: string; description?: string }): Promise<Namespace> {
|
|
const body: Record<string, string> = {}
|
|
if (request.displayName !== undefined) {
|
|
body.displayName = request.displayName.trim()
|
|
}
|
|
if (request.description !== undefined) {
|
|
body.description = request.description === '' ? '' : request.description.trim()
|
|
}
|
|
return fetchJson<Namespace>(`/api/v1/namespaces/${normalizeNamespaceSlug(slug)}`, {
|
|
method: 'PUT',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(body),
|
|
})
|
|
},
|
|
|
|
async transferOwnership(slug: string, newOwnerUserId: string): Promise<{ message: string }> {
|
|
return fetchJson<{ message: string }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/transfer-ownership`, {
|
|
method: 'POST',
|
|
headers: await ensureCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ newOwnerId: newOwnerUserId.trim() }),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const tokenApi = {
|
|
async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> {
|
|
const queryPage = params?.page ?? 0
|
|
const querySize = params?.size ?? 10
|
|
const page = await fetchJson<{ items: ApiToken[], total: number, page: number, size: number }>(
|
|
`/api/v1/tokens?page=${queryPage}&size=${querySize}`,
|
|
)
|
|
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 fetchJson<CreateTokenResponse>('/api/v1/tokens', {
|
|
method: 'POST',
|
|
headers: withCsrf({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify(request),
|
|
})
|
|
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; sortDirection?: 'ASC' | 'DESC' }) {
|
|
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))
|
|
searchParams.set('sortDirection', params.sortDirection ?? 'DESC')
|
|
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 getSkillDetail(id: number): Promise<ReviewSkillDetail> {
|
|
return fetchJson<ReviewSkillDetail>(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`)
|
|
},
|
|
|
|
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?: PromotionStatus; page?: number; size?: number; sortBy?: PromotionSortBy; sortDirection?: PromotionSortDirection }) {
|
|
const searchParams = new URLSearchParams()
|
|
searchParams.set('status', params.status ?? 'PENDING')
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
if (params.sortBy) {
|
|
searchParams.set('sortBy', params.sortBy)
|
|
}
|
|
if (params.sortDirection) {
|
|
searchParams.set('sortDirection', params.sortDirection)
|
|
}
|
|
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}/${encodeURIComponent(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<PagedResponse<GovernanceInboxItem>>(
|
|
`${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<PagedResponse<GovernanceActivityItem>>(
|
|
`${WEB_API_PREFIX}/governance/activity?${searchParams.toString()}`,
|
|
)
|
|
},
|
|
|
|
async getNotifications(params: { page?: number; size?: number }): Promise<PagedResponse<GovernanceNotification>> {
|
|
const searchParams = new URLSearchParams()
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
return fetchJson<PagedResponse<GovernanceNotification>>(`${WEB_API_PREFIX}/governance/notifications?${searchParams.toString()}`)
|
|
},
|
|
|
|
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; filter?: string; q?: string; namespace?: string }): 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))
|
|
if (params?.filter) {
|
|
searchParams.set('filter', params.filter)
|
|
}
|
|
if (params?.q) {
|
|
searchParams.set('q', params.q)
|
|
}
|
|
if (params?.namespace) {
|
|
searchParams.set('namespace', params.namespace)
|
|
}
|
|
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
|
|
},
|
|
|
|
async getSubscriptionsPage(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/subscriptions?${searchParams.toString()}`)
|
|
},
|
|
|
|
async getSubscriptions(): Promise<SkillSummary[]> {
|
|
const items: SkillSummary[] = []
|
|
let page = 0
|
|
const size = 100
|
|
let hasMore = true
|
|
|
|
while (hasMore) {
|
|
const response = await meApi.getSubscriptionsPage({ page, size })
|
|
items.push(...response.items)
|
|
|
|
hasMore = (page + 1) * response.size < response.total && response.items.length > 0
|
|
page++
|
|
}
|
|
|
|
return items
|
|
},
|
|
}
|
|
|
|
export const profileApi = {
|
|
async getProfile(): Promise<{
|
|
displayName: string
|
|
avatarUrl: string | null
|
|
email: string | null
|
|
pendingChanges: {
|
|
status: string
|
|
changes: Record<string, string>
|
|
reviewComment: string | null
|
|
createdAt: string
|
|
} | null
|
|
fieldPolicies: Record<string, { editable: boolean; requiresReview: boolean }>
|
|
}> {
|
|
return fetchJson('/api/v1/user/profile')
|
|
},
|
|
async updateProfile(request: Record<string, string>): Promise<{
|
|
status: string
|
|
appliedFields?: Record<string, string>
|
|
pendingFields?: Record<string, string>
|
|
}> {
|
|
return fetchJson<{ status: string; appliedFields?: Record<string, string>; pendingFields?: Record<string, 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 triggerPasswordReset(userId: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/users/${userId}/password-reset`, {
|
|
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 }),
|
|
})
|
|
},
|
|
|
|
async getProfileReviews(params: { status?: string; page?: number; size?: number; sortDirection?: 'ASC' | 'DESC' }) {
|
|
const searchParams = new URLSearchParams()
|
|
if (params.status) searchParams.set('status', params.status)
|
|
searchParams.set('page', String(params.page ?? 0))
|
|
searchParams.set('size', String(params.size ?? 20))
|
|
searchParams.set('sortDirection', params.sortDirection ?? 'DESC')
|
|
const response = await fetchJson<{
|
|
items: Array<{
|
|
id: number
|
|
userId: string
|
|
username: string
|
|
currentDisplayName: string | null
|
|
requestedDisplayName: string | null
|
|
status: string
|
|
machineResult: string | null
|
|
reviewerId: string | null
|
|
reviewerName: string | null
|
|
reviewComment: string | null
|
|
createdAt: string
|
|
reviewedAt: string | null
|
|
}>
|
|
total: number
|
|
page: number
|
|
size: number
|
|
}>(`/api/v1/admin/profile-reviews?${searchParams}`)
|
|
|
|
return {
|
|
...response,
|
|
totalElements: response.total,
|
|
totalPages: response.size > 0 ? Math.ceil(response.total / response.size) : 0,
|
|
}
|
|
},
|
|
|
|
async approveProfileReview(id: number): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/profile-reviews/${id}/approve`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async rejectProfileReview(id: number, comment: string): Promise<void> {
|
|
await fetchJson<void>(`/api/v1/admin/profile-reviews/${id}/reject`, {
|
|
method: 'POST',
|
|
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ comment }),
|
|
})
|
|
},
|
|
}
|
|
|
|
export const notificationApi = {
|
|
async list(params: { page?: number; size?: number; category?: string }) {
|
|
const searchParams = new URLSearchParams()
|
|
if (params.page !== undefined) searchParams.set('page', String(params.page))
|
|
if (params.size !== undefined) searchParams.set('size', String(params.size))
|
|
if (params.category) searchParams.set('category', params.category)
|
|
return fetchJson<{ items: NotificationItem[]; total: number; page: number; size: number }>(
|
|
`${WEB_API_PREFIX}/notifications?${searchParams.toString()}`,
|
|
)
|
|
},
|
|
|
|
async getUnreadCount() {
|
|
return fetchJson<NotificationUnreadCount>(`${WEB_API_PREFIX}/notifications/unread-count`)
|
|
},
|
|
|
|
async markRead(id: number) {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/notifications/${id}/read`, {
|
|
method: 'PUT',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async markAllRead() {
|
|
return fetchJson<{ count: number }>(`${WEB_API_PREFIX}/notifications/read-all`, {
|
|
method: 'PUT',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async deleteRead(id: number) {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/notifications/${id}`, {
|
|
method: 'DELETE',
|
|
headers: getCsrfHeaders(),
|
|
})
|
|
},
|
|
|
|
async getPreferences() {
|
|
return fetchJson<NotificationPreferenceItem[]>(`${WEB_API_PREFIX}/notification-preferences`)
|
|
},
|
|
|
|
async updatePreferences(preferences: NotificationPreferenceItem[]) {
|
|
await fetchJson<void>(`${WEB_API_PREFIX}/notification-preferences`, {
|
|
method: 'PUT',
|
|
headers: getCsrfHeaders({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ preferences }),
|
|
})
|
|
},
|
|
}
|