+22
-25
@@ -129,34 +129,31 @@ export function unbindIdentity(id: number): Promise<SessionRefresh> {
|
||||
|
||||
// ---- OAuth provider 配置(设置页) ----
|
||||
|
||||
const mockOauth: OAuthSettings = {
|
||||
oidcIssuer: '',
|
||||
oidcClientId: '',
|
||||
oidcSecretSet: false,
|
||||
oidcDisplayName: '',
|
||||
oidcDisabled: false,
|
||||
githubClientId: '',
|
||||
githubSecretSet: false,
|
||||
githubDisplayName: '',
|
||||
githubDisabled: false,
|
||||
}
|
||||
|
||||
export function getOAuthSettings(): Promise<OAuthSettings> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
oidcIssuer: '',
|
||||
oidcClientId: '',
|
||||
oidcSecretSet: false,
|
||||
oidcDisplayName: '',
|
||||
oidcDisabled: false,
|
||||
githubClientId: '',
|
||||
githubSecretSet: false,
|
||||
githubDisplayName: '',
|
||||
githubDisabled: false,
|
||||
})
|
||||
if (mockOn) return mocked({ ...mockOauth })
|
||||
return request('/settings/oauth')
|
||||
}
|
||||
|
||||
/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */
|
||||
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
oidcIssuer: body.oidcIssuer,
|
||||
oidcClientId: body.oidcClientId,
|
||||
oidcSecretSet: !!body.oidcClientSecret,
|
||||
oidcDisplayName: body.oidcDisplayName,
|
||||
oidcDisabled: body.oidcDisabled,
|
||||
githubClientId: body.githubClientId,
|
||||
githubSecretSet: !!body.githubClientSecret,
|
||||
githubDisplayName: body.githubDisplayName,
|
||||
githubDisabled: body.githubDisabled,
|
||||
})
|
||||
return request('/settings/oauth', { method: 'PUT', body })
|
||||
if (mockOn) {
|
||||
const { oidcClientSecret, githubClientSecret, ...rest } = body
|
||||
Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)))
|
||||
if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== ''
|
||||
if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== ''
|
||||
return mocked({ ...mockOauth })
|
||||
}
|
||||
return request('/settings/oauth', { method: 'PATCH', body })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { request, ApiError } from './request'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/** happy-dom 提供的当前页 URL 控制入口(标准 DOM 无此能力) */
|
||||
function setPageURL(url: string) {
|
||||
;(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url)
|
||||
}
|
||||
|
||||
/** 构造最小 401 响应;rotate 在响应返回前换发新会话,模拟 OAuth 绑定回跳 */
|
||||
function stub401(rotateTo?: string) {
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
if (rotateTo !== undefined) useAuthStore().setSession(rotateTo, '')
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
url: '/api/v1/x',
|
||||
json: async () => ({ error: '未认证' }),
|
||||
} as unknown as Response
|
||||
})
|
||||
}
|
||||
|
||||
let assignSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
assignSpy = vi.spyOn(location, 'assign').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('401 令牌快照', () => {
|
||||
it('当前 token 收到 401:登出并带 redirect 跳登录', async () => {
|
||||
setPageURL('http://localhost/instances?tab=list')
|
||||
useAuthStore().setSession('tokA', '')
|
||||
stub401()
|
||||
await expect(request('/x')).rejects.toMatchObject({ status: 401, message: '未认证' })
|
||||
expect(useAuthStore().token).toBe('')
|
||||
expect(assignSpy).toHaveBeenCalledWith(
|
||||
`/login?redirect=${encodeURIComponent('/instances?tab=list')}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('响应到达前会话已换新:旧 token 的迟到 401 不得清掉新会话', async () => {
|
||||
setPageURL('http://localhost/settings')
|
||||
useAuthStore().setSession('tokA', '')
|
||||
stub401('tokB')
|
||||
await expect(request('/x')).rejects.toBeInstanceOf(ApiError)
|
||||
expect(useAuthStore().token).toBe('tokB')
|
||||
expect(assignSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => {
|
||||
setPageURL('http://localhost/login')
|
||||
stub401()
|
||||
await expect(request('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({
|
||||
message: '未认证',
|
||||
})
|
||||
expect(assignSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+19
-4
@@ -32,7 +32,7 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
return s ? `${BASE}${path}?${s}` : BASE + path
|
||||
}
|
||||
|
||||
async function parseError(resp: Response): Promise<never> {
|
||||
async function parseError(resp: Response, sentToken: string): Promise<never> {
|
||||
let message = `请求失败(${resp.status})`
|
||||
let ociCode: string | undefined
|
||||
try {
|
||||
@@ -54,7 +54,7 @@ async function parseError(resp: Response): Promise<never> {
|
||||
} catch {
|
||||
/* 非 JSON 响应体,保留默认消息 */
|
||||
}
|
||||
if (resp.status === 401) useAuthStore().logout()
|
||||
if (resp.status === 401) handleUnauthorized(sentToken)
|
||||
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
|
||||
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
|
||||
location.assign('/blocked')
|
||||
@@ -62,17 +62,31 @@ async function parseError(resp: Response): Promise<never> {
|
||||
throw new ApiError(resp.status, message, ociCode)
|
||||
}
|
||||
|
||||
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
|
||||
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
|
||||
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
|
||||
function handleUnauthorized(sentToken: string) {
|
||||
const auth = useAuthStore()
|
||||
if (sentToken !== auth.token) return
|
||||
auth.logout()
|
||||
if (location.pathname !== '/login') {
|
||||
const redirect = encodeURIComponent(location.pathname + location.search)
|
||||
location.assign(`/login?redirect=${redirect}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
|
||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
|
||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||
const sentToken = auth.token
|
||||
const resp = await fetch(buildUrl(path, opts.query), {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
})
|
||||
if (!resp.ok) await parseError(resp)
|
||||
if (!resp.ok) await parseError(resp, sentToken)
|
||||
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
|
||||
const text = await resp.text()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
@@ -91,12 +105,13 @@ export async function rawFetch(
|
||||
const auth = useAuthStore()
|
||||
const headers: Record<string, string> = { ...opts.headers }
|
||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||
const sentToken = auth.token
|
||||
const resp = await fetch(buildUrl(path, opts.query), {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body,
|
||||
})
|
||||
if (!resp.ok) await parseError(resp)
|
||||
if (!resp.ok) await parseError(resp, sentToken)
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -139,13 +139,13 @@ export function getSecuritySetting(): Promise<SecuritySetting> {
|
||||
return request('/settings/security')
|
||||
}
|
||||
|
||||
/** 保存安全设置,返回最新值;越界或非法后端返回 400,保存后立即生效 */
|
||||
export function updateSecuritySetting(body: SecuritySetting): Promise<SecuritySetting> {
|
||||
/** 保存安全设置字段补丁(只落库出现字段),返回最新全量;越界或非法后端返回 400 */
|
||||
export function updateSecuritySetting(patch: Partial<SecuritySetting>): Promise<SecuritySetting> {
|
||||
if (mockOn) {
|
||||
Object.assign(mockSecuritySetting, body)
|
||||
Object.assign(mockSecuritySetting, patch)
|
||||
return mocked({ ...mockSecuritySetting }, 400)
|
||||
}
|
||||
return request('/settings/security', { method: 'PUT', body })
|
||||
return request('/settings/security', { method: 'PATCH', body: patch })
|
||||
}
|
||||
|
||||
export interface SystemLogParams {
|
||||
|
||||
+2
-5
@@ -66,10 +66,7 @@ export function listTaskLogs(id: number, limit = 50): Promise<TaskLog[]> {
|
||||
return request(`/tasks/${id}/logs`, { query: { limit } })
|
||||
}
|
||||
|
||||
export function runTask(id: number): Promise<TaskLog> {
|
||||
if (mockOn) {
|
||||
const logs = mockTaskLogs[id]
|
||||
return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000)
|
||||
}
|
||||
export function runTask(id: number): Promise<{ triggered: boolean }> {
|
||||
if (mockOn) return mocked({ triggered: true }, 300)
|
||||
return request(`/tasks/${id}/run`, { method: 'POST' })
|
||||
}
|
||||
|
||||
+30
-2
@@ -10,7 +10,7 @@ import {
|
||||
mockUserDetails,
|
||||
mockUsers,
|
||||
} from './mock'
|
||||
import { mockOn, mocked, request } from './request'
|
||||
import { mockOn, mocked, rawFetch, request } from './request'
|
||||
import type {
|
||||
AuditEventsResult,
|
||||
IamUser,
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
IamUserDetail,
|
||||
IdentityProvider,
|
||||
IdentitySetting,
|
||||
IdpIconUpload,
|
||||
LimitItem,
|
||||
LimitService,
|
||||
NotificationRecipients,
|
||||
@@ -298,7 +299,7 @@ export interface CreateIdpBody {
|
||||
name: string
|
||||
metadata: string
|
||||
description?: string
|
||||
/** logo:data URI 或外链 URL */
|
||||
/** logo:http(s) 外链 URL(可先经 IdP 图标上传接口取得) */
|
||||
iconUrl?: string
|
||||
nameIdFormat?: string
|
||||
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
|
||||
@@ -325,6 +326,33 @@ export function createIdp(id: number, body: CreateIdpBody, domainId?: string): P
|
||||
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } })
|
||||
}
|
||||
|
||||
/** 上传 IdP 图标到身份域公共图片存储,返回可填入 iconUrl 的公网地址(multipart 走 rawFetch) */
|
||||
export async function uploadIdpIcon(
|
||||
id: number,
|
||||
file: File,
|
||||
domainId?: string,
|
||||
): Promise<IdpIconUpload> {
|
||||
if (mockOn)
|
||||
return mocked({ url: 'https://mock.local/images/idp-icon.png', fileName: 'images/idp-icon.png' }, 600)
|
||||
const fd = new FormData()
|
||||
fd.append('file', file, file.name)
|
||||
const resp = await rawFetch(`/oci-configs/${id}/idp-icons`, {
|
||||
method: 'POST',
|
||||
query: { domainId },
|
||||
body: fd,
|
||||
})
|
||||
return (await resp.json()) as IdpIconUpload
|
||||
}
|
||||
|
||||
/** 删除尚未被 IdP 采用的身份域公共图片 */
|
||||
export function deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void> {
|
||||
if (mockOn) return mocked(undefined)
|
||||
return request(`/oci-configs/${id}/idp-icons`, {
|
||||
method: 'DELETE',
|
||||
query: { domainId, fileName },
|
||||
})
|
||||
}
|
||||
|
||||
export function listIdps(id: number, domainId?: string): Promise<IdentityProvider[]> {
|
||||
if (mockOn) return mocked(mockIdps)
|
||||
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })
|
||||
|
||||
Reference in New Issue
Block a user