feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
54
frontend/admin/src/services/account-bindings.test.ts
Normal file
54
frontend/admin/src/services/account-bindings.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const postMock = vi.fn()
|
||||
const delMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
post: postMock,
|
||||
del: delMock,
|
||||
}))
|
||||
|
||||
describe('account binding service', () => {
|
||||
beforeEach(() => {
|
||||
postMock.mockReset()
|
||||
delMock.mockReset()
|
||||
postMock.mockResolvedValue(undefined)
|
||||
delMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('sends email bind code through the protected endpoint', async () => {
|
||||
const { sendEmailBindCode } = await import('./account-bindings')
|
||||
|
||||
await sendEmailBindCode({ email: 'bind@example.com' })
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/users/me/bind-email/code', {
|
||||
email: 'bind@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('binds email with verification payload', async () => {
|
||||
const { bindEmail } = await import('./account-bindings')
|
||||
|
||||
await bindEmail({
|
||||
email: 'bind@example.com',
|
||||
code: '123456',
|
||||
current_password: 'SecurePass123',
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/users/me/bind-email', {
|
||||
email: 'bind@example.com',
|
||||
code: '123456',
|
||||
current_password: 'SecurePass123',
|
||||
})
|
||||
})
|
||||
|
||||
it('unbinds phone with optional sensitive verification', async () => {
|
||||
const { unbindPhone } = await import('./account-bindings')
|
||||
|
||||
await unbindPhone({ totp_code: '654321' })
|
||||
|
||||
expect(delMock).toHaveBeenCalledWith('/users/me/bind-phone', {
|
||||
body: { totp_code: '654321' },
|
||||
})
|
||||
})
|
||||
})
|
||||
35
frontend/admin/src/services/account-bindings.ts
Normal file
35
frontend/admin/src/services/account-bindings.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { del, post } from '@/lib/http/client'
|
||||
import type {
|
||||
BindEmailRequest,
|
||||
BindPhoneRequest,
|
||||
SensitiveActionVerification,
|
||||
SendEmailBindCodeRequest,
|
||||
SendPhoneBindCodeRequest,
|
||||
SendPhoneBindCodeResponse,
|
||||
} from '@/types'
|
||||
|
||||
export function sendEmailBindCode(payload: SendEmailBindCodeRequest): Promise<void> {
|
||||
return post<void>('/users/me/bind-email/code', payload)
|
||||
}
|
||||
|
||||
export function bindEmail(payload: BindEmailRequest): Promise<void> {
|
||||
return post<void>('/users/me/bind-email', payload)
|
||||
}
|
||||
|
||||
export function unbindEmail(payload?: SensitiveActionVerification): Promise<void> {
|
||||
return del<void>('/users/me/bind-email', { body: payload })
|
||||
}
|
||||
|
||||
export function sendPhoneBindCode(
|
||||
payload: SendPhoneBindCodeRequest,
|
||||
): Promise<SendPhoneBindCodeResponse> {
|
||||
return post<SendPhoneBindCodeResponse>('/users/me/bind-phone/code', payload)
|
||||
}
|
||||
|
||||
export function bindPhone(payload: BindPhoneRequest): Promise<void> {
|
||||
return post<void>('/users/me/bind-phone', payload)
|
||||
}
|
||||
|
||||
export function unbindPhone(payload?: SensitiveActionVerification): Promise<void> {
|
||||
return del<void>('/users/me/bind-phone', { body: payload })
|
||||
}
|
||||
206
frontend/admin/src/services/auth.test.ts
Normal file
206
frontend/admin/src/services/auth.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
}))
|
||||
|
||||
describe('auth service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
postMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('loads public auth capabilities without auth headers', async () => {
|
||||
const { getAuthCapabilities } = await import('./auth')
|
||||
|
||||
await getAuthCapabilities()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/auth/capabilities', undefined, { auth: false })
|
||||
})
|
||||
|
||||
it('normalizes null oauth provider lists from auth capabilities', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: undefined,
|
||||
oauth_providers: null,
|
||||
})
|
||||
|
||||
const { getAuthCapabilities } = await import('./auth')
|
||||
const result = await getAuthCapabilities()
|
||||
|
||||
expect(result.admin_bootstrap_required).toBe(false)
|
||||
expect(result.email_activation).toBe(false)
|
||||
expect(result.oauth_providers).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves admin bootstrap status from auth capabilities', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
password: true,
|
||||
email_activation: true,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
const { getAuthCapabilities } = await import('./auth')
|
||||
const result = await getAuthCapabilities()
|
||||
|
||||
expect(result.admin_bootstrap_required).toBe(true)
|
||||
expect(result.email_activation).toBe(true)
|
||||
})
|
||||
|
||||
it('requests oauth authorization url without auth headers', async () => {
|
||||
const { getOAuthAuthorizationUrl } = await import('./auth')
|
||||
|
||||
await getOAuthAuthorizationUrl('github', 'https://admin.example.com/login/oauth/callback')
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith(
|
||||
'/auth/oauth/github',
|
||||
{ return_to: 'https://admin.example.com/login/oauth/callback' },
|
||||
{ auth: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('exchanges oauth handoff code without auth headers', async () => {
|
||||
const { exchangeOAuthHandoff } = await import('./auth')
|
||||
|
||||
await exchangeOAuthHandoff('handoff-code')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/oauth/exchange',
|
||||
{ code: 'handoff-code' },
|
||||
{ auth: false, credentials: 'include' },
|
||||
)
|
||||
})
|
||||
|
||||
it('submits public registration without auth headers', async () => {
|
||||
const { register } = await import('./auth')
|
||||
|
||||
await register({
|
||||
username: 'new-user',
|
||||
password: 'SecurePass123!',
|
||||
email: 'new-user@example.com',
|
||||
nickname: 'New User',
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/register',
|
||||
{
|
||||
username: 'new-user',
|
||||
password: 'SecurePass123!',
|
||||
email: 'new-user@example.com',
|
||||
nickname: 'New User',
|
||||
},
|
||||
{ auth: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('submits first-admin bootstrap without auth headers', async () => {
|
||||
const { bootstrapAdmin } = await import('./auth')
|
||||
|
||||
await bootstrapAdmin({
|
||||
username: 'bootstrap_admin',
|
||||
password: 'Bootstrap123!@#',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
nickname: 'Bootstrap Admin',
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/bootstrap-admin',
|
||||
{
|
||||
username: 'bootstrap_admin',
|
||||
password: 'Bootstrap123!@#',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
nickname: 'Bootstrap Admin',
|
||||
},
|
||||
{ auth: false, credentials: 'include' },
|
||||
)
|
||||
})
|
||||
|
||||
it('activates email accounts without auth headers', async () => {
|
||||
const { activateEmail } = await import('./auth')
|
||||
|
||||
await activateEmail('activation-token')
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith(
|
||||
'/auth/activate',
|
||||
{ token: 'activation-token' },
|
||||
{ auth: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('resends activation emails without auth headers', async () => {
|
||||
const { resendActivationEmail } = await import('./auth')
|
||||
|
||||
await resendActivationEmail({ email: 'new-user@example.com' })
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/resend-activation',
|
||||
{ email: 'new-user@example.com' },
|
||||
{ auth: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('sends sms purpose instead of the deprecated scene field', async () => {
|
||||
const { sendSmsCode } = await import('./auth')
|
||||
|
||||
await sendSmsCode({
|
||||
phone: '13812345678',
|
||||
purpose: 'register',
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/send-code',
|
||||
{
|
||||
phone: '13812345678',
|
||||
purpose: 'register',
|
||||
},
|
||||
{ auth: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('sends refresh_token when logging out with a persisted session', async () => {
|
||||
const { logout } = await import('./auth')
|
||||
|
||||
await logout('refresh-token-demo')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/logout',
|
||||
{
|
||||
refresh_token: 'refresh-token-demo',
|
||||
},
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the request body when no refresh_token is available', async () => {
|
||||
const { logout } = await import('./auth')
|
||||
|
||||
await logout()
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/logout', undefined, { credentials: 'include' })
|
||||
})
|
||||
|
||||
it('refreshes the session with credentials even when no body token is supplied', async () => {
|
||||
const { refreshSession } = await import('./auth')
|
||||
|
||||
await refreshSession()
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/refresh',
|
||||
undefined,
|
||||
{ auth: false, credentials: 'include' },
|
||||
)
|
||||
})
|
||||
})
|
||||
147
frontend/admin/src/services/auth.ts
Normal file
147
frontend/admin/src/services/auth.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { get, post } from '@/lib/http/client'
|
||||
import type {
|
||||
ActionMessageResponse,
|
||||
AuthCapabilities,
|
||||
BootstrapAdminRequest,
|
||||
ForgotPasswordRequest,
|
||||
LoginByEmailCodeRequest,
|
||||
LoginByPasswordRequest,
|
||||
LoginBySmsCodeRequest,
|
||||
OAuthAuthorizationResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
ResendActivationEmailRequest,
|
||||
ResetPasswordRequest,
|
||||
SessionUser,
|
||||
SendEmailCodeRequest,
|
||||
SendSmsCodeRequest,
|
||||
TokenBundle,
|
||||
ValidateResetTokenResponse,
|
||||
} from '@/types'
|
||||
|
||||
function normalizeAuthCapabilities(capabilities?: Partial<AuthCapabilities> | null): AuthCapabilities {
|
||||
return {
|
||||
password: capabilities?.password ?? true,
|
||||
email_activation: capabilities?.email_activation ?? false,
|
||||
email_code: capabilities?.email_code ?? false,
|
||||
sms_code: capabilities?.sms_code ?? false,
|
||||
password_reset: capabilities?.password_reset ?? false,
|
||||
admin_bootstrap_required: capabilities?.admin_bootstrap_required ?? false,
|
||||
oauth_providers: Array.isArray(capabilities?.oauth_providers) ? capabilities.oauth_providers : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthCapabilities(): Promise<AuthCapabilities> {
|
||||
const capabilities = await get<Partial<AuthCapabilities>>('/auth/capabilities', undefined, { auth: false })
|
||||
return normalizeAuthCapabilities(capabilities)
|
||||
}
|
||||
|
||||
export function loginByPassword(data: LoginByPasswordRequest): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/login', data, { auth: false, credentials: 'include' })
|
||||
}
|
||||
|
||||
export function loginByEmailCode(data: LoginByEmailCodeRequest): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/login/email-code', data, { auth: false, credentials: 'include' })
|
||||
}
|
||||
|
||||
export function loginBySmsCode(data: LoginBySmsCodeRequest): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/login/code', data, { auth: false, credentials: 'include' })
|
||||
}
|
||||
|
||||
export function register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
return post<RegisterResponse>('/auth/register', data, { auth: false })
|
||||
}
|
||||
|
||||
export function bootstrapAdmin(data: BootstrapAdminRequest): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/bootstrap-admin', data, { auth: false, credentials: 'include' })
|
||||
}
|
||||
|
||||
export function activateEmail(token: string): Promise<ActionMessageResponse> {
|
||||
return get<ActionMessageResponse>('/auth/activate', { token }, { auth: false })
|
||||
}
|
||||
|
||||
export function resendActivationEmail(
|
||||
data: ResendActivationEmailRequest,
|
||||
): Promise<ActionMessageResponse> {
|
||||
return post<ActionMessageResponse>('/auth/resend-activation', data, { auth: false })
|
||||
}
|
||||
|
||||
export function sendEmailCode(data: SendEmailCodeRequest): Promise<void> {
|
||||
return post<void>('/auth/send-email-code', data, { auth: false })
|
||||
}
|
||||
|
||||
export function sendSmsCode(data: SendSmsCodeRequest): Promise<void> {
|
||||
return post<void>('/auth/send-code', data, { auth: false })
|
||||
}
|
||||
|
||||
export function refreshSession(refreshToken?: string | null): Promise<TokenBundle> {
|
||||
const body = refreshToken ? { refresh_token: refreshToken } : undefined
|
||||
return post<TokenBundle>('/auth/refresh', body, { auth: false, credentials: 'include' })
|
||||
}
|
||||
|
||||
export function getOAuthAuthorizationUrl(
|
||||
provider: string,
|
||||
returnTo: string,
|
||||
): Promise<OAuthAuthorizationResponse> {
|
||||
return get<OAuthAuthorizationResponse>(
|
||||
`/auth/oauth/${provider}`,
|
||||
{ return_to: returnTo },
|
||||
{ auth: false },
|
||||
)
|
||||
}
|
||||
|
||||
export function exchangeOAuthHandoff(code: string): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/oauth/exchange', { code }, { auth: false, credentials: 'include' })
|
||||
}
|
||||
|
||||
export function logout(refreshToken?: string | null): Promise<void> {
|
||||
return post<void>('/auth/logout', refreshToken ? { refresh_token: refreshToken } : undefined, {
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
export function getUserInfo(): Promise<SessionUser> {
|
||||
return get<SessionUser>('/auth/userinfo')
|
||||
}
|
||||
|
||||
export function forgotPassword(data: ForgotPasswordRequest): Promise<void> {
|
||||
return post<void>('/auth/forgot-password', data, { auth: false })
|
||||
}
|
||||
|
||||
export function validateResetToken(token: string): Promise<ValidateResetTokenResponse> {
|
||||
return get<ValidateResetTokenResponse>('/auth/reset-password', { token }, { auth: false })
|
||||
}
|
||||
|
||||
export function resetPassword(data: ResetPasswordRequest): Promise<void> {
|
||||
return post<void>('/auth/reset-password', data, { auth: false })
|
||||
}
|
||||
|
||||
export interface TotpStatus {
|
||||
totp_enabled: boolean
|
||||
}
|
||||
|
||||
export interface TotpSetup {
|
||||
secret: string
|
||||
qr_code_base64: string
|
||||
recovery_codes: string[]
|
||||
}
|
||||
|
||||
export function getTwoFactorStatus(): Promise<TotpStatus> {
|
||||
return get<TotpStatus>('/auth/2fa/status')
|
||||
}
|
||||
|
||||
export function getTwoFactorSetup(): Promise<TotpSetup> {
|
||||
return get<TotpSetup>('/auth/2fa/setup')
|
||||
}
|
||||
|
||||
export function enableTwoFactor(code: string): Promise<void> {
|
||||
return post<void>('/auth/2fa/enable', { code })
|
||||
}
|
||||
|
||||
export function disableTwoFactor(code: string): Promise<void> {
|
||||
return post<void>('/auth/2fa/disable', { code })
|
||||
}
|
||||
|
||||
export function verifyTwoFactor(code: string, deviceId?: string): Promise<void> {
|
||||
return post<void>('/auth/2fa/verify', { code, device_id: deviceId })
|
||||
}
|
||||
60
frontend/admin/src/services/devices.ts
Normal file
60
frontend/admin/src/services/devices.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { get, post, del, put } from '@/lib/http/client'
|
||||
import type { PaginatedData } from '@/types/http'
|
||||
import type {
|
||||
AdminDeviceListParams,
|
||||
Device,
|
||||
DeviceListParams,
|
||||
DeviceStatus,
|
||||
} from '@/types/device'
|
||||
|
||||
export function listDevices(params?: DeviceListParams): Promise<PaginatedData<Device>> {
|
||||
return get<PaginatedData<Device>>(
|
||||
'/devices',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
}
|
||||
|
||||
// 获取所有设备列表(管理员)
|
||||
export function listAllDevices(params?: AdminDeviceListParams): Promise<PaginatedData<Device>> {
|
||||
return get<PaginatedData<Device>>(
|
||||
'/admin/devices',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
}
|
||||
|
||||
export function getDevice(id: number): Promise<Device> {
|
||||
return get<Device>(`/devices/${id}`)
|
||||
}
|
||||
|
||||
export function deleteDevice(id: number): Promise<void> {
|
||||
return del<void>(`/devices/${id}`)
|
||||
}
|
||||
|
||||
export function updateDeviceStatus(id: number, status: DeviceStatus): Promise<void> {
|
||||
return put<void>(`/devices/${id}/status`, { status })
|
||||
}
|
||||
|
||||
// 信任设备(跳过2FA)
|
||||
export function trustDevice(id: number, trustDuration?: string): Promise<void> {
|
||||
return post<void>(`/devices/${id}/trust`, { trust_duration: trustDuration })
|
||||
}
|
||||
|
||||
// 信任设备(通过device_id字符串)
|
||||
export function trustDeviceByDeviceId(deviceId: string, trustDuration?: string): Promise<void> {
|
||||
return post<void>(`/devices/by-device-id/${encodeURIComponent(deviceId)}/trust`, { trust_duration: trustDuration })
|
||||
}
|
||||
|
||||
// 取消设备信任
|
||||
export function untrustDevice(id: number): Promise<void> {
|
||||
return del<void>(`/devices/${id}/trust`)
|
||||
}
|
||||
|
||||
// 获取我的信任设备列表
|
||||
export function getMyTrustedDevices(): Promise<Device[]> {
|
||||
return get<Device[]>('/devices/me/trusted')
|
||||
}
|
||||
|
||||
// 登出所有其他设备
|
||||
export function logoutOtherDevices(currentDeviceId: number): Promise<void> {
|
||||
return post<void>('/devices/me/logout-others', { current_device_id: currentDeviceId })
|
||||
}
|
||||
47
frontend/admin/src/services/import-export.ts
Normal file
47
frontend/admin/src/services/import-export.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { download, post } from '@/lib/http/client'
|
||||
import type {
|
||||
ExportUsersParams,
|
||||
ImportExportFormat,
|
||||
ImportUsersResult,
|
||||
} from '@/types/import-export'
|
||||
|
||||
const API_BASE = '/admin/users'
|
||||
|
||||
function triggerFileDownload(blob: Blob, filename: string) {
|
||||
const link = document.createElement('a')
|
||||
const objectUrl = window.URL.createObjectURL(blob)
|
||||
link.href = objectUrl
|
||||
link.setAttribute('download', filename)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
export async function exportUsers(params: ExportUsersParams): Promise<void> {
|
||||
const blob = await download(`${API_BASE}/export`, {
|
||||
format: params.format,
|
||||
fields: params.fields.join(','),
|
||||
keyword: params.keyword,
|
||||
status: params.status,
|
||||
})
|
||||
|
||||
triggerFileDownload(
|
||||
blob,
|
||||
`users_${new Date().toISOString().slice(0, 10)}.${params.format}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function downloadImportTemplate(
|
||||
format: ImportExportFormat,
|
||||
): Promise<void> {
|
||||
const blob = await download(`${API_BASE}/import/template`, { format })
|
||||
triggerFileDownload(blob, `users_import_template.${format}`)
|
||||
}
|
||||
|
||||
export function importUsers(file: File): Promise<ImportUsersResult> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return post<ImportUsersResult>(`${API_BASE}/import`, formData)
|
||||
}
|
||||
69
frontend/admin/src/services/login-logs.ts
Normal file
69
frontend/admin/src/services/login-logs.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { download, get } from '@/lib/http/client'
|
||||
import type { LoginLogListParams, LoginLogListResponse } from '@/types/login-log'
|
||||
|
||||
interface BackendListResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function listLoginLogs(params?: LoginLogListParams): Promise<LoginLogListResponse> {
|
||||
const result = await get<BackendListResponse<LoginLogListResponse['items'][number]>>(
|
||||
'/logs/login',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.list,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
page_size: result.size,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMyLoginLogs(params?: Pick<LoginLogListParams, 'page' | 'page_size'>): Promise<LoginLogListResponse> {
|
||||
const result = await get<BackendListResponse<LoginLogListResponse['items'][number]>>(
|
||||
'/logs/login/me',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.list,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
page_size: result.size,
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileDownload(blob: Blob, filename: string) {
|
||||
const link = document.createElement('a')
|
||||
const objectUrl = window.URL.createObjectURL(blob)
|
||||
link.href = objectUrl
|
||||
link.setAttribute('download', filename)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
export interface ExportLoginLogsParams {
|
||||
user_id?: number
|
||||
status?: number
|
||||
format?: 'csv' | 'xlsx'
|
||||
start_at?: string
|
||||
end_at?: string
|
||||
}
|
||||
|
||||
export async function exportLoginLogs(params?: ExportLoginLogsParams): Promise<void> {
|
||||
const format = params?.format || 'csv'
|
||||
const blob = await download('/logs/login/export', {
|
||||
user_id: params?.user_id,
|
||||
status: params?.status,
|
||||
format,
|
||||
start_at: params?.start_at,
|
||||
end_at: params?.end_at,
|
||||
})
|
||||
|
||||
triggerFileDownload(blob, `login_logs_${new Date().toISOString().slice(0, 10)}.${format}`)
|
||||
}
|
||||
39
frontend/admin/src/services/operation-logs.ts
Normal file
39
frontend/admin/src/services/operation-logs.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { get } from '@/lib/http/client'
|
||||
import type { OperationLogListParams, OperationLogListResponse } from '@/types/operation-log'
|
||||
|
||||
interface BackendListResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function listOperationLogs(params?: OperationLogListParams): Promise<OperationLogListResponse> {
|
||||
const result = await get<BackendListResponse<OperationLogListResponse['items'][number]>>(
|
||||
'/logs/operation',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.list,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
page_size: result.size,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMyOperationLogs(
|
||||
params?: Pick<OperationLogListParams, 'page' | 'page_size'>,
|
||||
): Promise<OperationLogListResponse> {
|
||||
const result = await get<BackendListResponse<OperationLogListResponse['items'][number]>>(
|
||||
'/logs/operation/me',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.list,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
page_size: result.size,
|
||||
}
|
||||
}
|
||||
64
frontend/admin/src/services/permissions.ts
Normal file
64
frontend/admin/src/services/permissions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 权限服务
|
||||
*
|
||||
* 提供权限管理相关 API 调用
|
||||
*/
|
||||
|
||||
import { get, post, put, del } from '@/lib/http/client'
|
||||
import type { Permission, CreatePermissionRequest, UpdatePermissionRequest } from '@/types/permission'
|
||||
|
||||
/**
|
||||
* 获取权限树
|
||||
* GET /api/v1/permissions/tree
|
||||
*/
|
||||
export function getPermissionTree(): Promise<Permission[]> {
|
||||
return get<Permission[]>('/permissions/tree')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有权限列表(扁平)
|
||||
* GET /api/v1/permissions
|
||||
*/
|
||||
export function listPermissions(): Promise<Permission[]> {
|
||||
return get<Permission[]>('/permissions')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取权限详情
|
||||
* GET /api/v1/permissions/:id
|
||||
*/
|
||||
export function getPermission(id: number): Promise<Permission> {
|
||||
return get<Permission>(`/permissions/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建权限
|
||||
* POST /api/v1/permissions
|
||||
*/
|
||||
export function createPermission(data: CreatePermissionRequest): Promise<Permission> {
|
||||
return post<Permission>('/permissions', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新权限
|
||||
* PUT /api/v1/permissions/:id
|
||||
*/
|
||||
export function updatePermission(id: number, data: UpdatePermissionRequest): Promise<Permission> {
|
||||
return put<Permission>(`/permissions/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除权限
|
||||
* DELETE /api/v1/permissions/:id
|
||||
*/
|
||||
export function deletePermission(id: number): Promise<void> {
|
||||
return del<void>(`/permissions/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新权限状态
|
||||
* PUT /api/v1/permissions/:id/status
|
||||
*/
|
||||
export function updatePermissionStatus(id: number, status: 0 | 1): Promise<void> {
|
||||
return put<void>(`/permissions/${id}/status`, { status })
|
||||
}
|
||||
70
frontend/admin/src/services/profile.ts
Normal file
70
frontend/admin/src/services/profile.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { get, post, put } from '@/lib/http/client'
|
||||
import type { Role } from '@/types/auth'
|
||||
import type { User, UpdateUserRequest } from '@/types/user'
|
||||
import { getUserRoles } from './users'
|
||||
|
||||
export interface CurrentUserProfile {
|
||||
user: User
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
export interface UpdatePasswordRequest {
|
||||
current_password: string
|
||||
new_password: string
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
export interface AvatarUploadResponse {
|
||||
avatar_url: string
|
||||
thumbnail: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface TOTPStatusResponse {
|
||||
totp_enabled: boolean
|
||||
}
|
||||
|
||||
export interface TOTPSetupResponse {
|
||||
secret: string
|
||||
qr_code_base64: string
|
||||
recovery_codes: string[]
|
||||
}
|
||||
|
||||
export async function getCurrentProfile(userId: number): Promise<CurrentUserProfile> {
|
||||
const [user, roles] = await Promise.all([
|
||||
get<User>(`/users/${userId}`),
|
||||
getUserRoles(userId),
|
||||
])
|
||||
|
||||
return { user, roles }
|
||||
}
|
||||
|
||||
export function updateProfile(userId: number, data: UpdateUserRequest): Promise<User> {
|
||||
return put<User>(`/users/${userId}`, data)
|
||||
}
|
||||
|
||||
export function uploadAvatar(userId: number, file: File): Promise<AvatarUploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', file)
|
||||
return post<AvatarUploadResponse>(`/users/${userId}/avatar`, formData)
|
||||
}
|
||||
|
||||
export function updatePassword(userId: number, data: UpdatePasswordRequest): Promise<void> {
|
||||
return put<void>(`/users/${userId}/password`, data)
|
||||
}
|
||||
|
||||
export function getTOTPStatus(): Promise<TOTPStatusResponse> {
|
||||
return get<TOTPStatusResponse>('/auth/2fa/status')
|
||||
}
|
||||
|
||||
export function getTOTPSetup(): Promise<TOTPSetupResponse> {
|
||||
return get<TOTPSetupResponse>('/auth/2fa/setup')
|
||||
}
|
||||
|
||||
export function enableTOTP(code: string): Promise<void> {
|
||||
return post<void>('/auth/2fa/enable', { code })
|
||||
}
|
||||
|
||||
export function disableTOTP(code: string): Promise<void> {
|
||||
return post<void>('/auth/2fa/disable', { code })
|
||||
}
|
||||
45
frontend/admin/src/services/roles.ts
Normal file
45
frontend/admin/src/services/roles.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { get, post, put, del } from '@/lib/http/client'
|
||||
import type { PaginatedData } from '@/types/http'
|
||||
import type {
|
||||
Role,
|
||||
RoleListParams,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
} from '@/types/role'
|
||||
import type { Permission } from '@/types/permission'
|
||||
|
||||
export function listRoles(params?: RoleListParams): Promise<PaginatedData<Role>> {
|
||||
return get<PaginatedData<Role>>(
|
||||
'/roles',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
}
|
||||
|
||||
export function getRole(id: number): Promise<Role> {
|
||||
return get<Role>(`/roles/${id}`)
|
||||
}
|
||||
|
||||
export function createRole(data: CreateRoleRequest): Promise<Role> {
|
||||
return post<Role>('/roles', data)
|
||||
}
|
||||
|
||||
export function updateRole(id: number, data: UpdateRoleRequest): Promise<Role> {
|
||||
return put<Role>(`/roles/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteRole(id: number): Promise<void> {
|
||||
return del<void>(`/roles/${id}`)
|
||||
}
|
||||
|
||||
export function updateRoleStatus(id: number, status: 0 | 1): Promise<void> {
|
||||
return put<void>(`/roles/${id}/status`, { status })
|
||||
}
|
||||
|
||||
export async function getRolePermissions(id: number): Promise<number[]> {
|
||||
const permissions = await get<Permission[]>(`/roles/${id}/permissions`)
|
||||
return permissions.map((permission) => permission.id)
|
||||
}
|
||||
|
||||
export function assignRolePermissions(id: number, permissionIds: number[]): Promise<void> {
|
||||
return put<void>(`/roles/${id}/permissions`, { permission_ids: permissionIds })
|
||||
}
|
||||
346
frontend/admin/src/services/service_adapters_additional.test.ts
Normal file
346
frontend/admin/src/services/service_adapters_additional.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const putMock = vi.fn()
|
||||
const delMock = vi.fn()
|
||||
const downloadMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
del: delMock,
|
||||
download: downloadMock,
|
||||
}))
|
||||
|
||||
describe('additional service adapters', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
putMock.mockReset()
|
||||
delMock.mockReset()
|
||||
downloadMock.mockReset()
|
||||
})
|
||||
|
||||
it('routes the remaining users service methods through the HTTP client', async () => {
|
||||
const {
|
||||
listUsers,
|
||||
getUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
updateUserStatus,
|
||||
getUserRoles,
|
||||
assignUserRoles,
|
||||
} = await import('./users')
|
||||
|
||||
await listUsers({ page: 2, page_size: 50, keyword: 'alice', status: 1, role_ids: '3' })
|
||||
expect(getMock).toHaveBeenCalledWith('/users', {
|
||||
page: 2,
|
||||
page_size: 50,
|
||||
keyword: 'alice',
|
||||
status: 1,
|
||||
role_ids: '3',
|
||||
})
|
||||
|
||||
await getUser(7)
|
||||
expect(getMock).toHaveBeenCalledWith('/users/7')
|
||||
|
||||
await updateUser(7, { nickname: 'Alice' })
|
||||
expect(putMock).toHaveBeenCalledWith('/users/7', { nickname: 'Alice' })
|
||||
|
||||
await deleteUser(7)
|
||||
expect(delMock).toHaveBeenCalledWith('/users/7')
|
||||
|
||||
await updateUserStatus(7, { status: 3 })
|
||||
expect(putMock).toHaveBeenCalledWith('/users/7/status', { status: 3 })
|
||||
|
||||
await getUserRoles(7)
|
||||
expect(getMock).toHaveBeenCalledWith('/users/7/roles')
|
||||
|
||||
await assignUserRoles(7, { role_ids: [1, 2] })
|
||||
expect(putMock).toHaveBeenCalledWith('/users/7/roles', { role_ids: [1, 2] })
|
||||
})
|
||||
|
||||
it('covers role, permission, device, and stats service endpoints', async () => {
|
||||
getMock
|
||||
.mockResolvedValueOnce({ items: [], total: 0, page: 1, page_size: 20 })
|
||||
.mockResolvedValueOnce({ id: 5, name: '管理员' })
|
||||
.mockResolvedValueOnce([{ id: 9 }, { id: 11 }])
|
||||
.mockResolvedValueOnce({ items: [], total: 0, page: 1, page_size: 20 })
|
||||
.mockResolvedValueOnce({ id: 3 })
|
||||
.mockResolvedValueOnce([{ id: 1, name: 'menu:view' }])
|
||||
.mockResolvedValueOnce([{ id: 2, name: 'menu:edit' }])
|
||||
.mockResolvedValueOnce({ total_users: 10 })
|
||||
.mockResolvedValueOnce({ active_users: 8 })
|
||||
|
||||
const {
|
||||
listRoles,
|
||||
getRole,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
updateRoleStatus,
|
||||
getRolePermissions,
|
||||
assignRolePermissions,
|
||||
} = await import('./roles')
|
||||
const {
|
||||
listDevices,
|
||||
getDevice,
|
||||
deleteDevice,
|
||||
updateDeviceStatus,
|
||||
} = await import('./devices')
|
||||
const {
|
||||
getPermissionTree,
|
||||
listPermissions,
|
||||
getPermission,
|
||||
createPermission,
|
||||
updatePermission,
|
||||
deletePermission,
|
||||
updatePermissionStatus,
|
||||
} = await import('./permissions')
|
||||
const { getDashboardStats, getUserStats } = await import('./stats')
|
||||
|
||||
await listRoles({ page: 1, page_size: 20 })
|
||||
expect(getMock).toHaveBeenCalledWith('/roles', { page: 1, page_size: 20 })
|
||||
|
||||
await getRole(5)
|
||||
expect(getMock).toHaveBeenCalledWith('/roles/5')
|
||||
|
||||
await createRole({ name: '审计员', code: 'auditor' })
|
||||
expect(postMock).toHaveBeenCalledWith('/roles', { name: '审计员', code: 'auditor' })
|
||||
|
||||
await updateRole(5, { description: 'updated' })
|
||||
expect(putMock).toHaveBeenCalledWith('/roles/5', { description: 'updated' })
|
||||
|
||||
await deleteRole(5)
|
||||
expect(delMock).toHaveBeenCalledWith('/roles/5')
|
||||
|
||||
await updateRoleStatus(5, 0)
|
||||
expect(putMock).toHaveBeenCalledWith('/roles/5/status', { status: 0 })
|
||||
|
||||
const permissionIds = await getRolePermissions(5)
|
||||
expect(getMock).toHaveBeenCalledWith('/roles/5/permissions')
|
||||
expect(permissionIds).toEqual([9, 11])
|
||||
|
||||
await assignRolePermissions(5, [9, 11])
|
||||
expect(putMock).toHaveBeenCalledWith('/roles/5/permissions', { permission_ids: [9, 11] })
|
||||
|
||||
await listDevices({ page: 1, page_size: 10 })
|
||||
expect(getMock).toHaveBeenCalledWith('/devices', { page: 1, page_size: 10 })
|
||||
|
||||
await getDevice(3)
|
||||
expect(getMock).toHaveBeenCalledWith('/devices/3')
|
||||
|
||||
await deleteDevice(3)
|
||||
expect(delMock).toHaveBeenCalledWith('/devices/3')
|
||||
|
||||
await updateDeviceStatus(3, 1)
|
||||
expect(putMock).toHaveBeenCalledWith('/devices/3/status', { status: 1 })
|
||||
|
||||
await getPermissionTree()
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
|
||||
|
||||
await listPermissions()
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions')
|
||||
|
||||
await getPermission(6)
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions/6')
|
||||
|
||||
await createPermission({ name: 'view dashboard', code: 'dashboard:view', type: 'menu' })
|
||||
expect(postMock).toHaveBeenCalledWith('/permissions', {
|
||||
name: 'view dashboard',
|
||||
code: 'dashboard:view',
|
||||
type: 'menu',
|
||||
})
|
||||
|
||||
await updatePermission(6, { name: 'updated permission' })
|
||||
expect(putMock).toHaveBeenCalledWith('/permissions/6', { name: 'updated permission' })
|
||||
|
||||
await deletePermission(6)
|
||||
expect(delMock).toHaveBeenCalledWith('/permissions/6')
|
||||
|
||||
await updatePermissionStatus(6, 0)
|
||||
expect(putMock).toHaveBeenCalledWith('/permissions/6/status', { status: 0 })
|
||||
|
||||
await getDashboardStats()
|
||||
expect(getMock).toHaveBeenCalledWith('/admin/stats/dashboard')
|
||||
|
||||
await getUserStats()
|
||||
expect(getMock).toHaveBeenCalledWith('/admin/stats/users')
|
||||
})
|
||||
|
||||
it('normalizes profile and log service responses and submits profile mutations', async () => {
|
||||
const file = new File(['avatar'], 'avatar.png', { type: 'image/png' })
|
||||
|
||||
getMock
|
||||
.mockResolvedValueOnce({ id: 1, username: 'admin' })
|
||||
.mockResolvedValueOnce([{ id: 2, name: '管理员' }])
|
||||
.mockResolvedValueOnce({ totp_enabled: true })
|
||||
.mockResolvedValueOnce({ secret: 'demo-secret', qr_code_base64: 'abc', recovery_codes: ['r1'] })
|
||||
.mockResolvedValueOnce({
|
||||
list: [{ id: 1, status: 1, login_type: 1 }],
|
||||
total: 1,
|
||||
page: 2,
|
||||
size: 30,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
list: [{ id: 2, status: 0, login_type: 3 }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 5,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
list: [{ id: 3, operation_name: 'create user' }],
|
||||
total: 1,
|
||||
page: 3,
|
||||
size: 40,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
list: [{ id: 4, operation_name: 'update profile' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 5,
|
||||
})
|
||||
|
||||
const {
|
||||
getCurrentProfile,
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
updatePassword,
|
||||
getTOTPStatus,
|
||||
getTOTPSetup,
|
||||
enableTOTP,
|
||||
disableTOTP,
|
||||
} = await import('./profile')
|
||||
const { listLoginLogs, listMyLoginLogs } = await import('./login-logs')
|
||||
const { listOperationLogs, listMyOperationLogs } = await import('./operation-logs')
|
||||
|
||||
const currentProfile = await getCurrentProfile(1)
|
||||
expect(currentProfile).toEqual({
|
||||
user: { id: 1, username: 'admin' },
|
||||
roles: [{ id: 2, name: '管理员' }],
|
||||
})
|
||||
expect(getMock).toHaveBeenNthCalledWith(1, '/users/1')
|
||||
expect(getMock).toHaveBeenNthCalledWith(2, '/users/1/roles')
|
||||
|
||||
await updateProfile(1, { nickname: 'Admin User' })
|
||||
expect(putMock).toHaveBeenCalledWith('/users/1', { nickname: 'Admin User' })
|
||||
|
||||
await uploadAvatar(1, file)
|
||||
expect(postMock).toHaveBeenCalledWith('/users/1/avatar', expect.any(FormData))
|
||||
const avatarPayload = postMock.mock.calls[0][1] as FormData
|
||||
expect(avatarPayload.get('avatar')).toBe(file)
|
||||
|
||||
await updatePassword(1, {
|
||||
current_password: 'CurrentPass123',
|
||||
new_password: 'NewPass123',
|
||||
confirm_password: 'NewPass123',
|
||||
})
|
||||
expect(putMock).toHaveBeenCalledWith('/users/1/password', {
|
||||
current_password: 'CurrentPass123',
|
||||
new_password: 'NewPass123',
|
||||
confirm_password: 'NewPass123',
|
||||
})
|
||||
|
||||
await expect(getTOTPStatus()).resolves.toEqual({ totp_enabled: true })
|
||||
expect(getMock).toHaveBeenCalledWith('/auth/2fa/status')
|
||||
|
||||
await expect(getTOTPSetup()).resolves.toEqual({
|
||||
secret: 'demo-secret',
|
||||
qr_code_base64: 'abc',
|
||||
recovery_codes: ['r1'],
|
||||
})
|
||||
expect(getMock).toHaveBeenCalledWith('/auth/2fa/setup')
|
||||
|
||||
await enableTOTP('123456')
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/2fa/enable', { code: '123456' })
|
||||
|
||||
await disableTOTP('654321')
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/2fa/disable', { code: '654321' })
|
||||
|
||||
await expect(listLoginLogs({ page: 2, page_size: 30, status: 1 })).resolves.toEqual({
|
||||
items: [{ id: 1, status: 1, login_type: 1 }],
|
||||
total: 1,
|
||||
page: 2,
|
||||
page_size: 30,
|
||||
})
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/login', { page: 2, page_size: 30, status: 1 })
|
||||
|
||||
await expect(listMyLoginLogs({ page: 1, page_size: 5 })).resolves.toEqual({
|
||||
items: [{ id: 2, status: 0, login_type: 3 }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
})
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/login/me', { page: 1, page_size: 5 })
|
||||
|
||||
await expect(listOperationLogs({ page: 3, page_size: 40, method: 'POST' })).resolves.toEqual({
|
||||
items: [{ id: 3, operation_name: 'create user' }],
|
||||
total: 1,
|
||||
page: 3,
|
||||
page_size: 40,
|
||||
})
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/operation', { page: 3, page_size: 40, method: 'POST' })
|
||||
|
||||
await expect(listMyOperationLogs({ page: 1, page_size: 5 })).resolves.toEqual({
|
||||
items: [{ id: 4, operation_name: 'update profile' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
})
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/operation/me', { page: 1, page_size: 5 })
|
||||
})
|
||||
|
||||
it('covers import-export download adapters and upload form submission', async () => {
|
||||
const blob = new Blob(['csv,data'], { type: 'text/csv' })
|
||||
const file = new File(['username'], 'users.csv', { type: 'text/csv' })
|
||||
const clickMock = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
const createObjectURLMock = vi.fn(() => 'blob:mock')
|
||||
const revokeObjectURLMock = vi.fn()
|
||||
|
||||
downloadMock.mockResolvedValue(blob)
|
||||
postMock.mockResolvedValue({
|
||||
success_count: 1,
|
||||
fail_count: 0,
|
||||
errors: [],
|
||||
message: 'ok',
|
||||
})
|
||||
|
||||
Object.defineProperty(window.URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectURLMock,
|
||||
})
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: revokeObjectURLMock,
|
||||
})
|
||||
|
||||
const { exportUsers, downloadImportTemplate, importUsers } = await import('./import-export')
|
||||
|
||||
await exportUsers({
|
||||
format: 'csv',
|
||||
fields: ['id', 'username'],
|
||||
keyword: 'alice',
|
||||
status: 1,
|
||||
})
|
||||
expect(downloadMock).toHaveBeenCalledWith('/admin/users/export', {
|
||||
format: 'csv',
|
||||
fields: 'id,username',
|
||||
keyword: 'alice',
|
||||
status: 1,
|
||||
})
|
||||
|
||||
await downloadImportTemplate('xlsx')
|
||||
expect(downloadMock).toHaveBeenCalledWith('/admin/users/import/template', { format: 'xlsx' })
|
||||
|
||||
await importUsers(file)
|
||||
expect(postMock).toHaveBeenCalledWith('/admin/users/import', expect.any(FormData))
|
||||
const importPayload = postMock.mock.calls[0][1] as FormData
|
||||
expect(importPayload.get('file')).toBe(file)
|
||||
|
||||
expect(createObjectURLMock).toHaveBeenCalledTimes(2)
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledTimes(2)
|
||||
expect(clickMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
56
frontend/admin/src/services/social-accounts.test.ts
Normal file
56
frontend/admin/src/services/social-accounts.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const delMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
del: delMock,
|
||||
}))
|
||||
|
||||
describe('social account service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
delMock.mockReset()
|
||||
getMock.mockResolvedValue([])
|
||||
postMock.mockResolvedValue({ auth_url: 'https://oauth.example.com', state: 'state-demo' })
|
||||
delMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('lists current user social accounts', async () => {
|
||||
const { listSocialAccounts } = await import('./social-accounts')
|
||||
|
||||
await listSocialAccounts()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/users/me/social-accounts')
|
||||
})
|
||||
|
||||
it('starts social binding with the current verification payload', async () => {
|
||||
const { startSocialBinding } = await import('./social-accounts')
|
||||
|
||||
await startSocialBinding({
|
||||
provider: 'github',
|
||||
return_to: '/profile/security',
|
||||
current_password: 'SecurePass123',
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/users/me/bind-social', {
|
||||
provider: 'github',
|
||||
return_to: '/profile/security',
|
||||
current_password: 'SecurePass123',
|
||||
})
|
||||
})
|
||||
|
||||
it('unbinds a social account with optional verification data', async () => {
|
||||
const { unbindSocialAccount } = await import('./social-accounts')
|
||||
|
||||
await unbindSocialAccount('github', { totp_code: '123456' })
|
||||
|
||||
expect(delMock).toHaveBeenCalledWith('/users/me/bind-social/github', {
|
||||
body: { totp_code: '123456' },
|
||||
})
|
||||
})
|
||||
})
|
||||
24
frontend/admin/src/services/social-accounts.ts
Normal file
24
frontend/admin/src/services/social-accounts.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { del, get, post } from '@/lib/http/client'
|
||||
import type {
|
||||
SocialAccountInfo,
|
||||
SocialAccountUnbindRequest,
|
||||
SocialBindingStartRequest,
|
||||
SocialBindingStartResponse,
|
||||
} from '@/types'
|
||||
|
||||
export function listSocialAccounts(): Promise<SocialAccountInfo[]> {
|
||||
return get<SocialAccountInfo[]>('/users/me/social-accounts')
|
||||
}
|
||||
|
||||
export function startSocialBinding(
|
||||
payload: SocialBindingStartRequest,
|
||||
): Promise<SocialBindingStartResponse> {
|
||||
return post<SocialBindingStartResponse>('/users/me/bind-social', payload)
|
||||
}
|
||||
|
||||
export function unbindSocialAccount(
|
||||
provider: string,
|
||||
payload?: SocialAccountUnbindRequest,
|
||||
): Promise<void> {
|
||||
return del<void>(`/users/me/bind-social/${provider}`, { body: payload })
|
||||
}
|
||||
24
frontend/admin/src/services/stats.ts
Normal file
24
frontend/admin/src/services/stats.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 统计服务
|
||||
*
|
||||
* 提供仪表盘和用户统计 API 调用
|
||||
*/
|
||||
|
||||
import { get } from '@/lib/http/client'
|
||||
import type { DashboardStats, UserStats } from '@/types/stats'
|
||||
|
||||
/**
|
||||
* 获取仪表盘统计数据
|
||||
* GET /api/v1/admin/stats/dashboard
|
||||
*/
|
||||
export function getDashboardStats(): Promise<DashboardStats> {
|
||||
return get<DashboardStats>('/admin/stats/dashboard')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计数据
|
||||
* GET /api/v1/admin/stats/users
|
||||
*/
|
||||
export function getUserStats(): Promise<UserStats> {
|
||||
return get<UserStats>('/admin/stats/users')
|
||||
}
|
||||
35
frontend/admin/src/services/users.test.ts
Normal file
35
frontend/admin/src/services/users.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const putMock = vi.fn()
|
||||
const delMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
del: delMock,
|
||||
}))
|
||||
|
||||
describe('users service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
putMock.mockReset()
|
||||
delMock.mockReset()
|
||||
})
|
||||
|
||||
it('creates a user through the protected users endpoint', async () => {
|
||||
const payload = {
|
||||
username: 'new-user',
|
||||
password: 'Pass123!@#',
|
||||
role_ids: [2],
|
||||
}
|
||||
|
||||
const { createUser } = await import('./users')
|
||||
await createUser(payload)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/users', payload)
|
||||
})
|
||||
})
|
||||
81
frontend/admin/src/services/users.ts
Normal file
81
frontend/admin/src/services/users.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 用户服务
|
||||
*
|
||||
* 提供用户管理相关 API 调用
|
||||
*/
|
||||
|
||||
import { get, post, put, del } from '@/lib/http/client'
|
||||
import type { PaginatedData } from '@/types/http'
|
||||
import type { Role } from '@/types/auth'
|
||||
import type {
|
||||
CreateUserRequest,
|
||||
User,
|
||||
UserListParams,
|
||||
UpdateUserRequest,
|
||||
UpdateUserStatusRequest,
|
||||
AssignUserRolesRequest,
|
||||
} from '@/types/user'
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
* GET /api/v1/users
|
||||
*/
|
||||
export function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
|
||||
return get<PaginatedData<User>>('/users', params as Record<string, string | number | boolean | undefined>)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
* GET /api/v1/users/:id
|
||||
*/
|
||||
export function getUser(id: number): Promise<User> {
|
||||
return get<User>(`/users/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* POST /api/v1/users
|
||||
*/
|
||||
export function createUser(data: CreateUserRequest): Promise<User> {
|
||||
return post<User>('/users', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
* PUT /api/v1/users/:id
|
||||
*/
|
||||
export function updateUser(id: number, data: UpdateUserRequest): Promise<User> {
|
||||
return put<User>(`/users/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* DELETE /api/v1/users/:id
|
||||
*/
|
||||
export function deleteUser(id: number): Promise<void> {
|
||||
return del<void>(`/users/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户状态
|
||||
* PUT /api/v1/users/:id/status
|
||||
*/
|
||||
export function updateUserStatus(id: number, data: UpdateUserStatusRequest): Promise<void> {
|
||||
return put<void>(`/users/${id}/status`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户角色列表
|
||||
* GET /api/v1/users/:id/roles
|
||||
*/
|
||||
export function getUserRoles(id: number): Promise<Role[]> {
|
||||
return get<Role[]>(`/users/${id}/roles`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配用户角色
|
||||
* PUT /api/v1/users/:id/roles
|
||||
*/
|
||||
export function assignUserRoles(id: number, data: AssignUserRolesRequest): Promise<void> {
|
||||
return put<void>(`/users/${id}/roles`, data)
|
||||
}
|
||||
122
frontend/admin/src/services/webhooks.test.ts
Normal file
122
frontend/admin/src/services/webhooks.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const putMock = vi.fn()
|
||||
const delMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
del: delMock,
|
||||
}))
|
||||
|
||||
describe('webhooks service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
putMock.mockReset()
|
||||
delMock.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes mixed raw event payloads from the API', async () => {
|
||||
getMock.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'String Events',
|
||||
url: 'https://example.com/string',
|
||||
events: '["user.registered"]',
|
||||
status: 1,
|
||||
max_retries: 3,
|
||||
timeout_sec: 10,
|
||||
created_by: 1,
|
||||
created_at: '2026-03-27 20:00:00',
|
||||
updated_at: '2026-03-27 20:00:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Array Events',
|
||||
url: 'https://example.com/array',
|
||||
events: ['user.login'],
|
||||
status: 0,
|
||||
max_retries: 3,
|
||||
timeout_sec: 10,
|
||||
created_by: 2,
|
||||
created_at: '2026-03-27 20:05:00',
|
||||
updated_at: '2026-03-27 20:05:00',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Invalid Events',
|
||||
url: 'https://example.com/invalid',
|
||||
events: 'not-json',
|
||||
status: 1,
|
||||
max_retries: 3,
|
||||
timeout_sec: 10,
|
||||
created_by: 3,
|
||||
created_at: '2026-03-27 20:10:00',
|
||||
updated_at: '2026-03-27 20:10:00',
|
||||
},
|
||||
])
|
||||
|
||||
const { listWebhooks } = await import('./webhooks')
|
||||
const result = await listWebhooks({ keyword: 'ignored' })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/webhooks', { keyword: 'ignored' })
|
||||
expect(result.data[0].events).toEqual(['user.registered'])
|
||||
expect(result.data[1].events).toEqual(['user.login'])
|
||||
expect(result.data[2].events).toEqual([])
|
||||
})
|
||||
|
||||
it('sends create, update, delete, and delivery requests through the HTTP client', async () => {
|
||||
postMock.mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Created Hook',
|
||||
url: 'https://example.com/created',
|
||||
events: '["user.updated"]',
|
||||
status: 1,
|
||||
max_retries: 3,
|
||||
timeout_sec: 10,
|
||||
created_by: 1,
|
||||
created_at: '2026-03-27 20:15:00',
|
||||
updated_at: '2026-03-27 20:15:00',
|
||||
})
|
||||
getMock.mockResolvedValue([])
|
||||
|
||||
const {
|
||||
createWebhook,
|
||||
updateWebhook,
|
||||
deleteWebhook,
|
||||
updateWebhookStatus,
|
||||
getWebhookDeliveries,
|
||||
} = await import('./webhooks')
|
||||
|
||||
const created = await createWebhook({
|
||||
name: 'Created Hook',
|
||||
url: 'https://example.com/created',
|
||||
secret: 'secret-demo',
|
||||
events: ['user.updated'],
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/webhooks', {
|
||||
name: 'Created Hook',
|
||||
url: 'https://example.com/created',
|
||||
secret: 'secret-demo',
|
||||
events: ['user.updated'],
|
||||
})
|
||||
expect(created.events).toEqual(['user.updated'])
|
||||
|
||||
await updateWebhook(9, { name: 'Updated Hook' })
|
||||
expect(putMock).toHaveBeenCalledWith('/webhooks/9', { name: 'Updated Hook' })
|
||||
|
||||
await updateWebhookStatus(9, 0)
|
||||
expect(putMock).toHaveBeenCalledWith('/webhooks/9', { status: 0 })
|
||||
|
||||
await deleteWebhook(9)
|
||||
expect(delMock).toHaveBeenCalledWith('/webhooks/9')
|
||||
|
||||
await getWebhookDeliveries(9, { limit: 20 })
|
||||
expect(getMock).toHaveBeenCalledWith('/webhooks/9/deliveries', { limit: 20 })
|
||||
})
|
||||
})
|
||||
74
frontend/admin/src/services/webhooks.ts
Normal file
74
frontend/admin/src/services/webhooks.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { get, post, put, del } from '@/lib/http/client'
|
||||
import type { Webhook } from '@/types/webhook'
|
||||
import type {
|
||||
CreateWebhookRequest,
|
||||
UpdateWebhookRequest,
|
||||
WebhookListParams,
|
||||
WebhookDelivery,
|
||||
WebhookDeliveryListParams,
|
||||
} from '@/types/webhook'
|
||||
|
||||
interface RawWebhook extends Omit<Webhook, 'events'> {
|
||||
events: string | string[]
|
||||
}
|
||||
|
||||
function parseEvents(value: string | string[]): Webhook['events'] {
|
||||
if (Array.isArray(value)) {
|
||||
return value as Webhook['events']
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? (parsed as Webhook['events']) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWebhook(webhook: RawWebhook): Webhook {
|
||||
return {
|
||||
...webhook,
|
||||
events: parseEvents(webhook.events),
|
||||
}
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export async function listWebhooks(
|
||||
params?: WebhookListParams,
|
||||
): Promise<{ data: Webhook[]; total: number; page: number; page_size: number }> {
|
||||
const result = await get<PaginatedResponse<RawWebhook>>('/webhooks', params as Record<string, string | number | boolean | undefined>)
|
||||
const webhooks = result.data.map(normalizeWebhook)
|
||||
return { data: webhooks, total: result.total, page: result.page, page_size: result.page_size }
|
||||
}
|
||||
|
||||
export function createWebhook(data: CreateWebhookRequest): Promise<Webhook> {
|
||||
return post<RawWebhook>('/webhooks', data).then(normalizeWebhook)
|
||||
}
|
||||
|
||||
export function updateWebhook(id: number, data: UpdateWebhookRequest): Promise<void> {
|
||||
return put<void>(`/webhooks/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteWebhook(id: number): Promise<void> {
|
||||
return del<void>(`/webhooks/${id}`)
|
||||
}
|
||||
|
||||
export function updateWebhookStatus(id: number, status: 0 | 1): Promise<void> {
|
||||
return updateWebhook(id, { status })
|
||||
}
|
||||
|
||||
export function getWebhookDeliveries(
|
||||
id: number,
|
||||
params?: WebhookDeliveryListParams,
|
||||
): Promise<WebhookDelivery[]> {
|
||||
return get<WebhookDelivery[]>(
|
||||
`/webhooks/${id}/deliveries`,
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user