feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs

This commit is contained in:
2026-04-02 11:20:20 +08:00
parent dcc1f186f8
commit 4718980ab5
235 changed files with 35682 additions and 0 deletions

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

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

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

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

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

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

View 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}`)
}

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

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

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

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

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

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

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

View 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')
}

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

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

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

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