feat(auth): 后台登录、角色权限与会话访问策略

管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
WangShen007 committed 2026-09-24 21:35:00 +08:00
1 parent 9eb8613c05
commit 556b650c7a
123 files changed
+25293

No files matched your search

@@ -0,0 +1,66 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
const updateOrganization = vi.fn()
vi.mock('@/composables/useAuth', () => ({
useAuth: () => ({ user: ref({ organization_id: 'org-1' }) }),
}))
vi.mock('@/services/organization', () => ({
organizationService: {
getOrganization: vi.fn(),
getOrganizationStats: vi.fn(),
updateOrganization: (...args: unknown[]) => updateOrganization(...args),
},
}))
const { useOrganizationSettings } = await import('@/composables/useOrganizationSettings')
describe('useOrganizationSettings – save errors', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the API detail when the domain is already taken', async () => {
updateOrganization.mockRejectedValueOnce({
message: 'Request failed with status code 409',
response: { status: 409, data: { detail: 'That domain is already used by another organization.' } },
})
const settings = useOrganizationSettings()
settings.formData.value.domain = 'localhost'
await settings.updateOrganization()
expect(updateOrganization).toHaveBeenCalledWith('org-1', expect.objectContaining({ domain: 'localhost' }))
expect(settings.error.value).toBe('That domain is already used by another organization.')
expect(settings.message.value).toBe('')
expect(settings.hasChanges.value).toBe(true)
})
it('falls back to a generic message when there is no API detail', async () => {
updateOrganization.mockRejectedValueOnce({})
const settings = useOrganizationSettings()
settings.formData.value.name = 'Renamed'
await settings.updateOrganization()
expect(settings.error.value).toBe('更新组织信息失败')
})
})
@@ -0,0 +1,88 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { User } from '@/types/user'
const resetUserPassword = vi.fn()
const toastSuccess = vi.fn()
const toastError = vi.fn()
vi.mock('@/services/users', () => ({
listUsers: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
resetUserPassword: (...args: unknown[]) => resetUserPassword(...args),
}))
vi.mock('vue-sonner', () => ({
toast: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}))
const { useUsers } = await import('@/composables/useUsers')
const agent = { id: 'agent-1', full_name: 'Agent One', email: 'agent@test.com' } as User
describe('useUsers – admin password reset', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('opens the modal for the selected user', () => {
const users = useUsers()
users.handleResetPassword(agent)
expect(users.showResetPasswordModal.value).toBe(true)
expect(users.selectedUser.value).toEqual(agent)
})
it('resets the password and closes the modal', async () => {
resetUserPassword.mockResolvedValueOnce(undefined)
const users = useUsers()
users.handleResetPassword(agent)
await users.confirmResetPassword('NewPassw0rd!')
expect(resetUserPassword).toHaveBeenCalledWith('agent-1', 'NewPassw0rd!')
expect(users.showResetPasswordModal.value).toBe(false)
expect(users.selectedUser.value).toBeNull()
expect(toastSuccess).toHaveBeenCalled()
expect(users.resettingPassword.value).toBe(false)
})
it('keeps the modal open and surfaces the API message on failure', async () => {
resetUserPassword.mockRejectedValueOnce({
response: { data: { detail: [{ msg: 'Password must be at least 8 characters long' }] } },
})
const users = useUsers()
users.handleResetPassword(agent)
await users.confirmResetPassword('short')
expect(users.showResetPasswordModal.value).toBe(true)
expect(toastError).toHaveBeenCalledWith(
'出错了',
expect.objectContaining({
description: '重置密码失败:Password must be at least 8 characters long',
}),
)
expect(users.resettingPassword.value).toBe(false)
})
})
@@ -0,0 +1,256 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, VueWrapper, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { createRouter, createWebHistory, Router } from 'vue-router'
import LoginView from '../../views/LoginView.vue'
import { createPinia, setActivePinia } from 'pinia'
// Mock the services
vi.mock('@/services/auth', () => ({
authService: {
login: vi.fn()
}
}))
// The landing route is resolved from the real permission checks reading the
// cached user, so this drives the user rather than mocking the checks away.
const currentUser = vi.hoisted(() => ({ value: null as unknown }))
vi.mock('@/services/user', () => ({
userService: {
getCurrentUser: () => currentUser.value,
setCurrentUser: vi.fn(),
getUserId: () => 'user-1',
},
}))
// Mock enterprise features
vi.mock('@/composables/useEnterpriseFeatures', () => ({
useEnterpriseFeatures: vi.fn(() => ({
hasEnterpriseModule: false,
loadModule: vi.fn().mockResolvedValue(null)
}))
}))
// Mock forgot password composable
vi.mock('@/composables/useForgotPassword', () => ({
useForgotPassword: vi.fn(() => ({
isLoading: { value: false },
error: { value: '' },
success: { value: '' },
currentStep: { value: 1 },
email: { value: '' },
otp: { value: '' },
newPassword: { value: '' },
confirmPassword: { value: '' },
passwordValidation: { value: { score: 0, hasMinLength: false, hasUpperCase: false, hasLowerCase: false, hasNumber: false, hasSpecialChar: false } },
requestPasswordReset: vi.fn(),
verifyAndResetPassword: vi.fn(),
resetForm: vi.fn(),
goBackToEmailStep: vi.fn()
}))
}))
// Mock Firebase services
vi.mock('@/services/firebase', () => ({
messaging: {},
requestNotificationPermission: vi.fn()
}))
vi.mock('@/composables/useNotifications', () => ({
useNotifications: vi.fn(() => ({
requestPermission: vi.fn(),
hasPermission: { value: false }
}))
}))
// Import the mocked modules
import { authService } from '@/services/auth'
import { userWithPermissions } from '../fixtures/permissions'
describe('LoginView', () => {
let wrapper: VueWrapper
let router: Router
beforeEach(async () => {
// Create a fresh router instance for each test
router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{ path: '/ai-agents', name: 'ai-agents', component: { template: '<div>AI Agents</div>' } },
{ path: '/conversations', name: 'conversations', component: { template: '<div>Conversations</div>' } },
{ path: '/human-agents', name: 'human-agents', component: { template: '<div>Human Agents</div>' } },
{ path: '/settings/organization', name: 'org-settings', component: { template: '<div>Organization Settings</div>' } },
{ path: '/settings/ai-config', name: 'ai-config', component: { template: '<div>AI Config</div>' } },
{ path: '/settings/user', name: 'user-settings', component: { template: '<div>User Settings</div>' } },
{ path: '/403', name: 'forbidden', component: { template: '<div>403</div>' } }
]
})
setActivePinia(createPinia())
vi.clearAllMocks()
// Reset router to initial state and wait for it to be ready
await router.push('/')
await router.isReady()
// A user with no grants unless a test says otherwise
currentUser.value = userWithPermissions([])
wrapper = mount(LoginView, {
global: {
plugins: [router],
stubs: {
RouterView: true
}
}
})
// Wait for component to be ready
await nextTick()
})
it('renders login form properly', () => {
expect(wrapper.find('.auth-page').exists()).toBe(true)
expect(wrapper.find('input[type="email"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
})
it('updates email and password inputs', async () => {
const emailInput = wrapper.find('input[type="email"]')
const passwordInput = wrapper.find('input[type="password"]')
await emailInput.setValue('test@example.com')
await passwordInput.setValue('password123')
expect((emailInput.element as HTMLInputElement).value).toBe('test@example.com')
expect((passwordInput.element as HTMLInputElement).value).toBe('password123')
})
it('shows loading state during login', async () => {
const mockLogin = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)))
;(authService.login as any) = mockLogin
await wrapper.find('input[type="email"]').setValue('test@example.com')
await wrapper.find('input[type="password"]').setValue('password123')
const submitButton = wrapper.find('button[type="submit"]')
await submitButton.trigger('submit')
expect(submitButton.text()).toBe('登录中…')
expect(submitButton.attributes('disabled')).toBeDefined()
})
it('handles successful login and redirects based on permissions', async () => {
// Mock successful login
;(authService.login as any).mockResolvedValue({ id: 1, email: 'test@example.com' })
// User can manage agents
currentUser.value = userWithPermissions(['manage_agents'])
await wrapper.find('input[type="email"]').setValue('test@example.com')
await wrapper.find('input[type="password"]').setValue('password123')
await wrapper.find('form').trigger('submit')
// Verify login was called
expect(authService.login).toHaveBeenCalledWith('test@example.com', 'password123')
// Wait for all promises to resolve
await flushPromises()
expect(router.currentRoute.value.path).toBe('/ai-agents')
})
it('shows the backend message authService.login rethrows', async () => {
// What the real service throws: a plain Error carrying the API's detail.
;(authService.login as any).mockRejectedValue(new Error('Incorrect email or password'))
await wrapper.find('input[type="email"]').setValue('someone@example.com')
await wrapper.find('input[type="password"]').setValue('wrong')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('.auth-error').text()).toBe('Incorrect email or password')
})
it('handles login error', async () => {
const errorMessage = 'Invalid credentials'
;(authService.login as any).mockRejectedValue({
response: {
data: {
detail: errorMessage
}
}
})
await wrapper.find('input[type="email"]').setValue('test@example.com')
await wrapper.find('input[type="password"]').setValue('wrong-password')
await wrapper.find('form').trigger('submit')
// Wait for error to be displayed
await flushPromises()
expect(wrapper.find('.auth-error').text()).toBe(errorMessage)
})
it('redirects to correct route based on permissions', async () => {
;(authService.login as any).mockResolvedValue({ id: 1, email: 'test@example.com' })
const testCases = [
{ permission: 'manage_agents', route: '/ai-agents' },
{ permission: 'view_assigned_chats', route: '/conversations' },
{ permission: 'manage_users', route: '/human-agents' },
{ permission: 'view_organization', route: '/settings/organization' },
{ permission: 'view_ai_config', route: '/settings/ai-config' },
]
for (const testCase of testCases) {
currentUser.value = userWithPermissions([testCase.permission])
// Reset router and wait for it to be ready
await router.push('/')
await router.isReady()
await nextTick()
await wrapper.find('input[type="email"]').setValue('test@example.com')
await wrapper.find('input[type="password"]').setValue('password123')
await wrapper.find('form').trigger('submit')
// Wait for all promises to resolve
await flushPromises()
expect(router.currentRoute.value.path).toBe(testCase.route)
}
})
// Used to land on /403 — which was not a registered route, so the user
// silently ended up on /ai-agents, a page they had just been refused.
it('lands a permissionless user on their own settings, never on 403', async () => {
;(authService.login as any).mockResolvedValue({ id: 1, email: 'test@example.com' })
currentUser.value = userWithPermissions([])
await wrapper.find('input[type="email"]').setValue('test@example.com')
await wrapper.find('input[type="password"]').setValue('password123')
await wrapper.find('form').trigger('submit')
// Wait for all promises to resolve
await flushPromises()
expect(router.currentRoute.value.path).toBe('/settings/user')
})
})
@@ -0,0 +1,291 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import SetupView from '../../views/SetupView.vue'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
// Mock timezone-select-js
vi.mock('timezone-select-js', () => ({
listTz: () => [
{ value: 'America/New_York', label: 'New York' },
{ value: 'Europe/London', label: 'London' },
{ value: 'Asia/Tokyo', label: 'Tokyo' }
],
clientTz: () => 'America/New_York'
}))
// Mock the organization service
vi.mock('@/services/organization', () => ({
createOrganization: vi.fn(),
getSetupStatus: vi.fn().mockResolvedValue(false)
}))
// Mock Firebase services
vi.mock('@/services/firebase', () => ({
messaging: {},
requestNotificationPermission: vi.fn()
}))
vi.mock('@/composables/useNotifications', () => ({
useNotifications: vi.fn(() => ({
requestPermission: vi.fn(),
hasPermission: { value: false }
}))
}))
// Import mocked services
import { createOrganization, getSetupStatus } from '@/services/organization'
describe('SetupView', () => {
let wrapper: VueWrapper
let router: any
beforeEach(async () => {
// Create a fresh pinia instance
setActivePinia(createPinia())
// Create router instance
router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{ path: '/ai-agents', name: 'ai-agents', component: { template: '<div>AI Agents</div>' } }
]
})
// Reset mocks
vi.clearAllMocks()
// Initialize router
await router.push('/')
await router.isReady()
wrapper = mount(SetupView, {
global: {
plugins: [router]
}
})
// Wait for initial organization check
await nextTick()
})
it('renders properly', () => {
expect(wrapper.find('.setup').exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('欢迎使用 ReplyPier')
})
it('checks for existing organization on mount', async () => {
expect(getSetupStatus).toHaveBeenCalled()
})
it('redirects to ai-agents if organization exists', async () => {
// Mock organization exists
vi.mocked(getSetupStatus).mockResolvedValueOnce(true)
// Remount component
wrapper = mount(SetupView, {
global: {
plugins: [router]
}
})
// Wait for all promises to resolve and navigation to complete
await flushPromises()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/login')
expect(getSetupStatus).toHaveBeenCalled()
})
it('displays form fields with default values', () => {
expect(wrapper.find('#orgName').exists()).toBe(true)
expect(wrapper.find('#domain').exists()).toBe(true)
expect(wrapper.find('#timezone').exists()).toBe(true)
expect(wrapper.find('#adminName').exists()).toBe(true)
expect(wrapper.find('#adminEmail').exists()).toBe(true)
expect(wrapper.find('#adminPassword').exists()).toBe(true)
expect(wrapper.find('#confirmPassword').exists()).toBe(true)
})
it('validates organization name', async () => {
const orgNameInput = wrapper.find('#orgName')
// Test invalid name
await orgNameInput.setValue('a')
await orgNameInput.trigger('input')
expect(wrapper.find('.error-hint').text()).toContain('组织名称需为')
// Test valid name
await orgNameInput.setValue('Valid Organization')
await orgNameInput.trigger('input')
expect(wrapper.find('.error-hint').exists()).toBe(false)
})
it('validates domain', async () => {
const domainInput = wrapper.find('#domain')
// Test invalid domain
await domainInput.setValue('invalid')
await domainInput.trigger('input')
expect(wrapper.find('.error-hint').text()).toContain('请输入有效的域名')
// Test valid domain
await domainInput.setValue('example.com')
await domainInput.trigger('input')
expect(wrapper.find('.error-hint').exists()).toBe(false)
})
it('validates admin email', async () => {
const emailInput = wrapper.find('#adminEmail')
// Test invalid email
await emailInput.setValue('invalid-email')
await emailInput.trigger('input')
expect(wrapper.find('.error-hint').text()).toContain('请输入有效的邮箱地址')
// Test valid email
await emailInput.setValue('admin@example.com')
await emailInput.trigger('input')
expect(wrapper.find('.error-hint').exists()).toBe(false)
})
it('validates password strength', async () => {
const passwordInput = wrapper.find('#adminPassword')
// Test weak password
await passwordInput.setValue('weak')
await passwordInput.trigger('input')
expect(wrapper.find('.strength-bar').classes()).toContain('weak')
// Test strong password
await passwordInput.setValue('StrongP@ss123')
await passwordInput.trigger('input')
expect(wrapper.find('.strength-bar').classes()).toContain('strong')
})
it('validates password confirmation', async () => {
// Fill in all required fields with valid data first
await wrapper.find('#orgName').setValue('Test Organization')
await wrapper.find('#domain').setValue('test.com')
await wrapper.find('#adminName').setValue('Test Admin')
await wrapper.find('#adminEmail').setValue('admin@test.com')
// Set different passwords
const passwordInput = wrapper.find('#adminPassword')
const confirmInput = wrapper.find('#confirmPassword')
await passwordInput.setValue('StrongP@ss123')
await passwordInput.trigger('input')
await confirmInput.setValue('DifferentP@ss123')
// Submit form
await wrapper.find('form').trigger('submit')
await nextTick()
expect(wrapper.find('.error-message').text()).toBe('两次输入的密码不一致')
})
it('handles timezone selection', async () => {
const timezoneSelect = wrapper.find('#timezone')
await timezoneSelect.setValue('Europe/London')
expect((wrapper.vm as any).selectedTimezone).toBe('Europe/London')
expect((wrapper.vm as any).orgData.timezone).toBe('Europe/London')
})
it('handles business hours configuration', async () => {
// Toggle Saturday to enabled
const saturdayToggle = wrapper.findAll('.toggle input')[5]
await saturdayToggle.setValue(true)
// Change Saturday hours
const timeSelects = wrapper.findAll('.time-selects select')
await timeSelects[10].setValue('10:00') // Start time
await timeSelects[11].setValue('18:00') // End time
const businessHours = (wrapper.vm as any).orgData.business_hours
expect(businessHours.saturday.enabled).toBe(true)
expect(businessHours.saturday.start).toBe('10:00')
expect(businessHours.saturday.end).toBe('18:00')
})
it('submits form with valid data', async () => {
// Fill in all required fields with valid data
await wrapper.find('#orgName').setValue('Test Organization')
await wrapper.find('#domain').setValue('test.com')
await wrapper.find('#adminName').setValue('Test Admin')
await wrapper.find('#adminEmail').setValue('admin@test.com')
await wrapper.find('#adminPassword').setValue('StrongP@ss123')
await wrapper.find('#confirmPassword').setValue('StrongP@ss123')
// Submit form
await wrapper.find('form').trigger('submit')
await nextTick()
// Verify organization creation was called
expect(createOrganization).toHaveBeenCalledWith(expect.objectContaining({
name: 'Test Organization',
domain: 'test.com',
admin_name: 'Test Admin',
admin_email: 'admin@test.com',
admin_password: 'StrongP@ss123'
}))
})
it('handles organization creation error', async () => {
const error = 'Failed to create organization'
;(createOrganization as any).mockRejectedValueOnce(new Error(error))
// Fill in required fields
await wrapper.find('#orgName').setValue('Test Organization')
await wrapper.find('#domain').setValue('test.com')
await wrapper.find('#adminName').setValue('Test Admin')
await wrapper.find('#adminEmail').setValue('admin@test.com')
await wrapper.find('#adminPassword').setValue('StrongP@ss123')
await wrapper.find('#confirmPassword').setValue('StrongP@ss123')
// Submit form
await wrapper.find('form').trigger('submit')
await nextTick()
expect(wrapper.find('.error-message').text()).toBe(error)
})
it('shows loading state during submission', async () => {
// Mock slow organization creation
;(createOrganization as any).mockImplementationOnce(() => new Promise(resolve => setTimeout(resolve, 100)))
// Fill in required fields
await wrapper.find('#orgName').setValue('Test Organization')
await wrapper.find('#domain').setValue('test.com')
await wrapper.find('#adminName').setValue('Test Admin')
await wrapper.find('#adminEmail').setValue('admin@test.com')
await wrapper.find('#adminPassword').setValue('StrongP@ss123')
await wrapper.find('#confirmPassword').setValue('StrongP@ss123')
// Submit form
await wrapper.find('form').trigger('submit')
await nextTick()
expect(wrapper.find('button[type="submit"]').classes()).toContain('loading')
})
})