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

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

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { Toaster } from 'vue-sonner'
</script>
<template>
<Toaster position="top-right" />
<RouterView />
</template>
<style>
@import './assets/styles/main.css';
</style>
@@ -0,0 +1,66 @@
/*
Copyright 2024-2026 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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('欢迎使用 ChatterMate')
})
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')
})
})
@@ -0,0 +1,192 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
/**
* The modal shell: scrim, panel, titled header with a close button, and the
* behaviour a dialog owes a keyboard.
*
* Extracted because three WhatsApp modals had grown byte-identical copies of
* all of it — same scrim, same panel, same header, same buttons, same
* role/aria-modal/tabindex/click.self/keydown.esc scaffolding, differing only
* in a CSS prefix. Each copy was also missing a focus trap in the same way, and
* fixing that three times is how the fourth modal ends up without one.
*
* Slots: default (body) and `actions` (footer buttons). `width` sizes the panel.
* `dismissible: false` closes it only from its own buttons — no ×, scrim
* click or Escape — for a dialog whose content is lost once closed.
*/
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = withDefaults(defineProps<{
title: string
/** Panel width; the shell caps it to the viewport. */
width?: string
dismissible?: boolean
}>(), { width: '520px', dismissible: true })
const dismiss = () => {
if (props.dismissible) emit('close')
}
const emit = defineEmits<{ (e: 'close'): void }>()
const dialog = ref<HTMLElement | null>(null)
/** Whatever had focus before we opened — where it must go back to. */
let previouslyFocused: HTMLElement | null = null
const FOCUSABLE = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])',
].join(',')
/**
* Keep Tab inside the dialog.
*
* aria-modal="true" tells assistive tech that everything behind this is
* hidden. Without a trap that's a lie the first time the user tabs past the
* last button: they land in content their screen reader has been told does not
* exist, with no way back but Escape.
*/
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
dismiss()
return
}
if (event.key !== 'Tab' || !dialog.value) return
const focusable = Array.from(
dialog.value.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((element) => element.offsetParent !== null)
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement as HTMLElement | null
// Wrap at both ends. The container itself is focusable (tabindex="-1") and
// is where focus starts, so treat it as "before the first".
if (event.shiftKey && (active === first || active === dialog.value)) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && active === last) {
event.preventDefault()
first.focus()
}
}
onMounted(() => {
previouslyFocused = document.activeElement as HTMLElement | null
dialog.value?.focus()
})
onBeforeUnmount(() => {
// Returning focus to the trigger is the other half of the contract: without
// it, closing drops the user back at the top of the document.
previouslyFocused?.focus?.()
})
</script>
<template>
<div
ref="dialog"
class="bm-scrim"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
@click.self="dismiss"
@keydown="onKeydown"
>
<div class="bm-panel" :style="{ '--bm-width': props.width }">
<div class="bm-header">
<h3 class="bm-title">{{ title }}</h3>
<button v-if="dismissible" class="bm-close" aria-label="关闭" @click="emit('close')">×</button>
</div>
<div class="bm-body">
<slot />
</div>
<div v-if="$slots.actions" class="bm-actions">
<slot name="actions" />
</div>
</div>
</div>
</template>
<style scoped>
.bm-scrim {
position: fixed;
inset: 0;
background: var(--scrim);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.bm-scrim:focus {
outline: none;
}
.bm-panel {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 12px);
width: min(var(--bm-width, 520px), 100%);
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
padding: 24px;
overflow-y: auto;
}
.bm-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.bm-title {
margin: 0;
font-family: var(--font-display);
}
.bm-close {
background: none;
border: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--muted);
}
.bm-body {
display: flex;
flex-direction: column;
min-height: 0;
}
.bm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
</style>
@@ -0,0 +1,58 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
channel?: string | null
}>()
const CHANNEL_LABELS: Record<string, string> = {
shopify: 'Shopify',
telegram: 'Telegram',
whatsapp: 'WhatsApp',
messenger: 'Messenger',
instagram: 'Instagram',
slack: 'Slack',
email: 'Email',
sms: 'SMS',
line: 'LINE',
api: 'API',
}
// The widget ('web') is the default context — no badge needed for it.
const label = computed(() => (props.channel ? CHANNEL_LABELS[props.channel] : null))
</script>
<template>
<span v-if="label" class="channel-badge" :class="`channel-${channel}`">{{ label }}</span>
</template>
<style scoped>
.channel-badge {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: var(--radius-chip, 999px);
font-size: 11px;
font-weight: 600;
line-height: 1.6;
background: var(--accent-bg-12, rgba(59, 130, 246, 0.12));
color: var(--accent-ink, #2563eb);
white-space: nowrap;
}
</style>
@@ -0,0 +1,795 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { resolveWidgetUploadUrl } from '../../webclient/widget-env'
// Security: Allowed file types (matching backend)
const ALLOWED_FILE_TYPES = new Set([
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/csv', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
])
const EXTENSION_TO_MIME: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.txt': 'text/plain', '.csv': 'text/csv', '.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
// Validate extension matches MIME type
function validateExtension(filename: string, mimeType: string): { valid: boolean; error?: string } {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
if (!ext || ext === '.') return { valid: false, error: '文件必须包含扩展名' }
if (!(ext in EXTENSION_TO_MIME)) {
return { valid: false, error: `不支持该文件类型。允许的类型:${Object.keys(EXTENSION_TO_MIME).map(e => e.replace('.', '').toUpperCase()).join(', ')}` }
}
const expectedMime = EXTENSION_TO_MIME[ext]
if ((mimeType === 'image/jpeg' || mimeType === 'image/jpg') && (expectedMime === 'image/jpeg' || expectedMime === 'image/jpg')) return { valid: true }
if (expectedMime !== mimeType) return { valid: false, error: `文件扩展名 ${ext} 与内容类型不匹配` }
return { valid: true }
}
// Validate magic bytes
async function validateMagicBytes(file: File): Promise<{ valid: boolean; error?: string }> {
const mimeType = file.type
if (mimeType === 'text/plain' || mimeType === 'text/csv') return { valid: true }
if (mimeType.includes('openxmlformats') || mimeType === 'application/msword') {
const buffer = await file.slice(0, 4).arrayBuffer()
const bytes = new Uint8Array(buffer)
if ((bytes[0] === 0x50 && bytes[1] === 0x4b) || (bytes[0] === 0xd0 && bytes[1] === 0xcf)) return { valid: true }
return { valid: false, error: '文件内容与 Office 文档格式不匹配' }
}
const MAGIC_BYTES: Record<string, number[][]> = {
'image/jpeg': [[0xff, 0xd8, 0xff, 0xe0], [0xff, 0xd8, 0xff, 0xe1], [0xff, 0xd8, 0xff, 0xdb]],
'image/png': [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
'image/gif': [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
'application/pdf': [[0x25, 0x50, 0x44, 0x46, 0x2d]]
}
const signatures = MAGIC_BYTES[mimeType]
if (!signatures) return { valid: true }
const buffer = await file.slice(0, 16).arrayBuffer()
const fileBytes = new Uint8Array(buffer)
if (mimeType === 'image/webp') {
if (fileBytes[0] === 0x52 && fileBytes[1] === 0x49 && fileBytes[2] === 0x46 && fileBytes[3] === 0x46 &&
fileBytes[8] === 0x57 && fileBytes[9] === 0x45 && fileBytes[10] === 0x42 && fileBytes[11] === 0x50) return { valid: true }
return { valid: false, error: '文件内容与 WebP 格式不匹配' }
}
for (const sig of signatures) {
let match = true
for (let i = 0; i < sig.length; i++) { if (fileBytes[i] !== sig[i]) { match = false; break } }
if (match) return { valid: true }
}
return { valid: false, error: `文件内容与 ${mimeType} 格式不匹配` }
}
const props = defineProps<{
token?: string
authorization?: string
maxFiles?: number
acceptTypes?: string
}>()
const emit = defineEmits<{
(e: 'filesUploaded', files: Array<{content: string, filename: string, content_type: string, size: number}>): void
(e: 'error', error: string): void
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadedFiles = ref<Array<{file: File, content: string, url: string, file_url: string, filename: string, type: string, size: number}>>([])
const uploading = ref(false)
const dragOver = ref(false)
const maxFiles = computed(() => props.maxFiles || 3)
const acceptTypes = computed(() => props.acceptTypes || 'image/*,.pdf,.doc,.docx,.txt,.csv,.xlsx,.xls')
const canUploadMore = computed(() => uploadedFiles.value.length < maxFiles.value)
const handleFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
await uploadFiles(Array.from(target.files))
// Reset the input value to allow selecting the same file again
target.value = ''
}
}
const handleDrop = async (event: DragEvent) => {
event.preventDefault()
dragOver.value = false
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
await uploadFiles(Array.from(event.dataTransfer.files))
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
dragOver.value = true
}
const handleDragLeave = () => {
dragOver.value = false
}
const handlePaste = async (event: ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return
const files: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
files.push(file)
}
}
}
if (files.length > 0) {
event.preventDefault()
await uploadFiles(files)
}
}
// Compress image if it's too large
const compressImage = async (file: File, maxSizeKB: number = 500): Promise<{blob: Blob, base64: string}> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
let width = img.width
let height = img.height
// Calculate new dimensions (max 1920px width/height while maintaining aspect ratio)
const maxDimension = 1920
if (width > maxDimension || height > maxDimension) {
if (width > height) {
height = (height / width) * maxDimension
width = maxDimension
} else {
width = (width / height) * maxDimension
height = maxDimension
}
}
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Failed to get canvas context'))
return
}
ctx.drawImage(img, 0, 0, width, height)
// Start with quality 0.9 and reduce if needed
let quality = 0.9
const tryCompress = () => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to compress image'))
return
}
const sizeKB = blob.size / 1024
// If still too large and quality can be reduced, try again
if (sizeKB > maxSizeKB && quality > 0.3) {
quality -= 0.1
tryCompress()
} else {
// Convert blob to base64
const reader = new FileReader()
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1]
resolve({ blob, base64 })
}
reader.readAsDataURL(blob)
}
}, file.type === 'image/png' ? 'image/png' : 'image/jpeg', quality)
}
tryCompress()
}
img.onerror = () => reject(new Error('Failed to load image'))
img.src = e.target?.result as string
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
const uploadFiles = async (files: File[]) => {
if (!canUploadMore.value) {
emit('error', `最多允许 ${maxFiles.value} 个文件`)
return
}
const remainingSlots = maxFiles.value - uploadedFiles.value.length
const filesToUpload = files.slice(0, remainingSlots)
if (files.length > remainingSlots) {
emit('error', `最多还能上传 ${remainingSlots} 个文件`)
}
const TARGET_SIZE_KB = 500 // Target 500KB after compression
// Read files and store them temporarily (will be uploaded when message is sent)
for (const file of filesToUpload) {
try {
// Check if file with same name already exists
const isDuplicate = uploadedFiles.value.some(f => f.filename === file.name)
if (isDuplicate) {
console.warn(`File ${file.name} is already selected`)
emit('error', `文件“${file.name}”已被选择`)
continue
}
// SECURITY: Validate file type is allowed
if (!ALLOWED_FILE_TYPES.has(file.type)) {
const allowed = Object.keys(EXTENSION_TO_MIME).map(e => e.toUpperCase().replace('.', '')).join(', ')
emit('error', `不支持文件类型“${file.type}”。允许的类型:${allowed}`)
continue
}
// SECURITY: Validate extension matches MIME type
const extValidation = validateExtension(file.name, file.type)
if (!extValidation.valid) {
emit('error', `安全检查失败:${extValidation.error}`)
continue
}
// SECURITY: Validate magic bytes
const magicValidation = await validateMagicBytes(file)
if (!magicValidation.valid) {
emit('error', `安全检查失败:${magicValidation.error}`)
continue
}
const isImage = file.type.startsWith('image/')
if (isImage) {
// Compress image before upload
try {
const { blob, base64 } = await compressImage(file, TARGET_SIZE_KB)
const compressedSize = blob.size
console.log(`Compressed ${file.name}: ${(file.size / 1024).toFixed(2)}KB → ${(compressedSize / 1024).toFixed(2)}KB`)
uploadedFiles.value.push({
file: file,
content: base64,
filename: file.name,
type: file.type,
size: compressedSize,
url: URL.createObjectURL(blob),
file_url: URL.createObjectURL(blob)
})
// Emit the files after compression
emit('filesUploaded', uploadedFiles.value.map(f => ({
content: f.content,
filename: f.filename,
content_type: f.type,
size: f.size
})))
} catch (error) {
console.error('Image compression failed, uploading original:', error)
// Fallback to original file if compression fails
const reader = new FileReader()
reader.onload = (e) => {
const base64Content = e.target?.result as string
const base64Data = base64Content.split(',')[1]
uploadedFiles.value.push({
file: file,
content: base64Data,
filename: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
url: URL.createObjectURL(file),
file_url: URL.createObjectURL(file)
})
emit('filesUploaded', uploadedFiles.value.map(f => ({
content: f.content,
filename: f.filename,
content_type: f.type,
size: f.size
})))
}
reader.readAsDataURL(file)
}
} else {
// For non-images, read as-is
const reader = new FileReader()
reader.onload = (e) => {
const base64Content = e.target?.result as string
const base64Data = base64Content.split(',')[1]
uploadedFiles.value.push({
file: file,
content: base64Data,
filename: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
url: '',
file_url: ''
})
emit('filesUploaded', uploadedFiles.value.map(f => ({
content: f.content,
filename: f.filename,
content_type: f.type,
size: f.size
})))
}
reader.readAsDataURL(file)
}
} catch (error) {
console.error('File read error:', error)
emit('error', `读取文件 ${file.name} 失败`)
}
}
}
const removeFile = (index: number) => {
const file = uploadedFiles.value[index]
if (!file) return
// Revoke object URL if it exists to free memory
if (file.url && file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url)
}
if (file.file_url && file.file_url.startsWith('blob:')) {
URL.revokeObjectURL(file.file_url)
}
// Remove from local array
uploadedFiles.value.splice(index, 1)
emit('filesUploaded', uploadedFiles.value.map(f => ({
content: f.content,
filename: f.filename,
content_type: f.type,
size: f.size
})))
}
const openFilePicker = () => {
fileInput.value?.click()
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const isImage = (type: string | undefined | null): boolean => {
return type ? type.startsWith('image/') : false
}
// Generate preview URL for file display
const getPreviewUrl = (file: {url: string, file_url: string}): string => {
// If file_url is a blob URL (temporary preview), return as-is
if (file.file_url.startsWith('blob:')) {
return file.file_url
}
// Absolute S3 URLs pass through; local paths (including
// /api/v1/files/download/...) already carry the prefix, so they resolve
// against the API origin rather than being concatenated onto the API base.
return resolveWidgetUploadUrl(file.file_url)
}
defineExpose({
uploadedFiles,
clearFiles: () => { uploadedFiles.value = [] },
handlePaste
})
</script>
<template>
<div class="file-upload-container">
<!-- Hidden file input -->
<input
ref="fileInput"
type="file"
:accept="acceptTypes"
multiple
style="display: none"
@change="handleFileSelect"
/>
<!-- Upload button -->
<button
v-if="canUploadMore"
type="button"
class="upload-button"
:disabled="uploading"
@click="openFilePicker"
:title="'附加文件(或粘贴截图)'"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<!-- Drop zone overlay -->
<div
v-if="dragOver"
class="drop-zone-overlay"
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
>
<div class="drop-zone-content">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p>拖拽文件到此处</p>
</div>
</div>
<!-- File previews -->
<div v-if="uploadedFiles.length > 0" class="file-previews">
<div
v-for="(file, index) in uploadedFiles"
:key="index"
class="file-preview"
>
<div class="file-preview-content">
<img
v-if="isImage(file.type)"
:src="getPreviewUrl(file)"
:alt="file.filename"
class="file-preview-image"
/>
<div v-else class="file-preview-icon">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
</div>
</div>
<div class="file-preview-info">
<div class="file-preview-name">{{ file.filename }}</div>
<div class="file-preview-size">{{ formatFileSize(file.size) }}</div>
</div>
<button
type="button"
class="file-preview-remove"
@click="removeFile(index)"
:title="'删除文件'"
>
×
</button>
</div>
</div>
<!-- Upload progress -->
<div v-if="uploading" class="upload-progress">
<div class="upload-spinner"></div>
<span>上传中……</span>
</div>
</div>
</template>
<style scoped>
.file-upload-container {
position: relative;
}
.upload-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
background: transparent;
color: #666;
cursor: pointer;
border-radius: 50%;
transition: all 0.2s;
padding: 0;
}
.upload-button:hover:not(:disabled) {
background: #f0f0f0;
color: #333;
}
.upload-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.drop-zone-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.drop-zone-content {
background: var(--surface);
padding: 40px;
border-radius: 12px;
text-align: center;
color: #333;
}
.drop-zone-content svg {
margin-bottom: 16px;
}
.drop-zone-content p {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.file-previews {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.03) 0%, rgba(59, 130, 246, 0.01) 100%);
border-radius: 12px;
margin-bottom: 10px;
border: 1px dashed rgba(59, 130, 246, 0.2);
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.file-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
border: 2px solid #e5e7eb;
border-radius: 14px;
font-size: 13px;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.04),
0 1px 2px rgba(0, 0, 0, 0.02);
max-width: 100%;
overflow: hidden;
}
.file-preview::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(180deg,
#3b82f6 0%,
#60a5fa 50%,
#93c5fd 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.file-preview:hover {
box-shadow:
0 8px 16px rgba(59, 130, 246, 0.12),
0 4px 8px rgba(59, 130, 246, 0.08);
transform: translateY(-2px);
border-color: #93c5fd;
}
.file-preview:hover::before {
opacity: 1;
}
.file-preview-content {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
flex-shrink: 0;
overflow: hidden;
position: relative;
box-shadow:
0 2px 8px rgba(59, 130, 246, 0.1),
inset 0 1px 2px rgba(255, 255, 255, 0.5);
}
.file-preview-content::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.4) 0%,
transparent 100%);
pointer-events: none;
}
.file-preview-image {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 10px;
position: relative;
z-index: 1;
transition: transform 0.3s ease;
}
.file-preview:hover .file-preview-image {
transform: scale(1.05);
}
.file-preview-icon {
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
position: relative;
z-index: 1;
transition: transform 0.3s ease;
}
.file-preview:hover .file-preview-icon {
transform: scale(1.1) rotate(-5deg);
}
.file-preview-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.file-preview-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
color: #1f2937;
font-size: 13px;
line-height: 1.4;
letter-spacing: -0.01em;
}
.file-preview-size {
font-size: 11px;
color: #9ca3af;
font-weight: 500;
letter-spacing: 0.01em;
}
.file-preview-remove {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
min-width: 28px;
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
border: 1.5px solid #fca5a5;
border-radius: 8px;
color: #dc2626;
cursor: pointer;
font-size: 20px;
font-weight: bold;
padding: 0;
flex-shrink: 0;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
line-height: 1;
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.1);
}
.file-preview-remove:hover {
background: linear-gradient(135deg, #fca5a5 0%, #f87171 100%);
border-color: #ef4444;
color: white;
transform: scale(1.1) rotate(90deg);
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.25);
}
.file-preview-remove:active {
transform: scale(1) rotate(90deg);
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2);
}
.upload-progress {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
color: #666;
font-size: 13px;
}
.upload-spinner {
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #666;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,181 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
/**
* A textarea for prompt text an AI reads, rather than a label a human reads.
* Monospace because the content is index globs and field names, and it ships
* a worked example behind a disclosure — a placeholder vanishes on the first
* keystroke, which is exactly when the operator still needs the shape.
*/
import { computed, ref } from 'vue'
import { MAX_GUIDANCE_CHARS } from '@/utils/mcp'
const props = withDefaults(
defineProps<{
// Nullable: the API returns null for a connector nobody has documented,
// and callers bind that straight through.
modelValue: string | null | undefined
label: string
hint: string
placeholder?: string
example?: string
rows?: number
maxlength?: number
}>(),
{ placeholder: '', example: '', rows: 6, maxlength: MAX_GUIDANCE_CHARS },
)
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
const showExample = ref(false)
const text = computed(() => props.modelValue ?? '')
const isNearLimit = computed(() => text.value.length > props.maxlength * 0.9)
</script>
<template>
<div class="guidance">
<span class="guidance-label">{{ label }}</span>
<p class="guidance-hint">{{ hint }}</p>
<textarea
class="guidance-input"
:value="text"
:aria-label="label"
:rows="rows"
:maxlength="maxlength"
:placeholder="placeholder"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
></textarea>
<div class="guidance-foot">
<button v-if="example" type="button" class="example-toggle" @click="showExample = !showExample">
<font-awesome-icon :icon="['fas', showExample ? 'chevron-up' : 'chevron-down']" />
{{ showExample ? '隐藏示例' : '查看示例' }}
</button>
<span v-else></span>
<span class="guidance-count mono" :class="{ near: isNearLimit }">
{{ text.length }} / {{ maxlength }}
</span>
</div>
<div v-if="example && showExample" class="guidance-example">
<pre class="example-text mono">{{ example }}</pre>
<!-- Offered only while the field is empty, so it can never overwrite
something the operator actually wrote. -->
<button
v-if="!text.length"
type="button"
class="example-use"
@click="emit('update:modelValue', example)"
>
用作起点
</button>
</div>
</div>
</template>
<style scoped>
.guidance {
display: flex;
flex-direction: column;
gap: 5px;
}
.guidance-label {
font-size: 11px;
color: var(--faint);
}
.guidance-hint {
font-size: 11.5px;
color: var(--muted);
line-height: 1.5;
margin: 0;
}
.guidance-input {
padding: 9px 11px;
min-height: 130px;
background: var(--bg2);
border: 1px solid var(--o10);
border-radius: 9px;
color: var(--text);
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.55;
outline: none;
resize: vertical;
}
.guidance-input:focus {
border-color: var(--accent-ink);
}
.guidance-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.example-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
background: none;
border: none;
color: var(--muted);
font-size: 11.5px;
cursor: pointer;
}
.example-toggle:hover {
color: var(--text);
}
.guidance-count {
font-size: 10.5px;
color: var(--muted2);
}
.guidance-count.near {
color: var(--c-warn);
}
.guidance-example {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 11px 13px;
background: var(--surface);
border: 1px solid var(--o07);
border-radius: 9px;
}
.example-text {
margin: 0;
font-size: 11px;
line-height: 1.6;
color: var(--text3);
white-space: pre-wrap;
overflow-x: auto;
max-width: 100%;
}
.example-use {
padding: 5px 11px;
background: var(--o05);
border: 1px solid var(--o10);
border-radius: 8px;
color: var(--muted);
font-size: 11.5px;
cursor: pointer;
}
.example-use:hover {
color: var(--text);
}
.mono {
font-family: var(--font-mono);
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { useId } from 'vue'
const titleId = useId()
const props = withDefaults(defineProps<{
title?: string
// When true, clicking the backdrop does NOT close the modal — for forms
// where an accidental outside-click would discard the user's input.
persistent?: boolean
}>(), { persistent: false })
const emit = defineEmits<{
close: []
}>()
const onOverlayClick = () => {
if (!props.persistent) emit('close')
}
</script>
<template>
<div class="modal-overlay" @click="onOverlayClick">
<div class="modal-content" role="dialog" aria-modal="true" :aria-labelledby="titleId" @click.stop>
<div class="modal-header">
<h3 :id="titleId" class="modal-title">
<slot name="title"></slot>
</h3>
<button class="close-button" aria-label="关闭" @click="$emit('close')">&times;</button>
</div>
<div class="modal-body">
<slot name="content"></slot>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(5, 6, 9, 0.66);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 100;
}
.modal-content {
background: var(--surface);
border: 1px solid var(--o10);
border-radius: 20px;
padding: 28px;
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 22px;
}
.modal-title {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
color: var(--text);
margin: 0;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--o05);
border: 1px solid var(--o12);
border-radius: 8px;
font-size: 18px;
color: var(--muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.close-button:hover {
background: var(--o10);
color: var(--text);
}
.modal-body {
margin-top: 0;
}
</style>
@@ -0,0 +1,114 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { PasswordStrength } from '@/utils/validators'
const props = defineProps<{
strength: PasswordStrength
}>()
const TOTAL_CHECKS = 5
const strengthClass = computed(() => {
if (props.strength.score >= 4) return 'strong'
if (props.strength.score >= 3) return 'medium'
return 'weak'
})
const requirements = computed(() => [
{ label: '至少 8 个字符', met: props.strength.hasMinLength },
{ label: '至少一个大写字母', met: props.strength.hasUpperCase },
{ label: '至少一个小写字母', met: props.strength.hasLowerCase },
{ label: '至少一个数字', met: props.strength.hasNumber },
{ label: '至少一个特殊字符', met: props.strength.hasSpecialChar },
])
</script>
<template>
<div class="password-strength">
<div class="strength-meter">
<div
class="strength-bar"
:class="strengthClass"
:style="{ width: `${(strength.score / TOTAL_CHECKS) * 100}%` }"
/>
</div>
<ul class="strength-requirements">
<li v-for="req in requirements" :key="req.label" :class="{ met: req.met }">
{{ req.label }}
</li>
</ul>
</div>
</template>
<style scoped>
.password-strength {
margin-top: var(--space-sm);
}
.strength-meter {
height: 4px;
background: var(--background-mute);
border-radius: var(--radius-full);
margin-bottom: var(--space-sm);
}
.strength-bar {
height: 100%;
border-radius: var(--radius-full);
transition: width var(--transition-normal);
}
.strength-bar.weak {
background: var(--error-color);
}
.strength-bar.medium {
background: var(--warning-color);
}
.strength-bar.strong {
background: var(--success-color);
}
.strength-requirements {
list-style: none;
padding: 0;
margin: 0;
font-size: var(--text-sm);
color: var(--text-color);
opacity: 0.7;
}
.strength-requirements li {
margin-bottom: var(--space-xs);
display: flex;
align-items: center;
gap: var(--space-xs);
}
.strength-requirements li::before {
content: '×';
color: var(--error-color);
}
.strength-requirements li.met::before {
content: '✓';
color: var(--success-color);
}
</style>
@@ -0,0 +1,99 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref } from 'vue'
import type { UserGroup } from '@/types/user'
const props = defineProps<{
group?: UserGroup | null
}>()
const emit = defineEmits<{
submit: [groupData: Partial<UserGroup>]
cancel: []
}>()
const formData = ref({
name: props.group?.name || '',
description: props.group?.description || ''
})
const handleSubmit = () => {
emit('submit', formData.value)
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="group-form">
<div class="form-group">
<label for="name">名称</label>
<input
id="name"
v-model="formData.name"
type="text"
required
class="form-input"
/>
</div>
<div class="form-group">
<label for="description">描述</label>
<textarea
id="description"
v-model="formData.description"
class="form-input"
rows="3"
/>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
取消
</button>
<button type="submit" class="btn btn-primary">
{{ props.group ? '更新' : '创建' }}
</button>
</div>
</form>
</template>
<style scoped>
.group-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.form-input {
padding: var(--space-sm);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-md);
margin-top: var(--space-md);
}
</style>
@@ -0,0 +1,544 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import Modal from '@/components/common/Modal.vue'
import GroupForm from './GroupForm.vue'
import { useGroups } from '@/composables/useGroups'
import type { User, UserGroup } from '@/types/user'
const props = defineProps<{
searchQuery?: string
}>()
const emit = defineEmits<{
(e: 'changed'): void
}>()
const {
groups,
users,
loading,
error,
showCreateModal,
showEditModal,
showMembersModal,
showDeleteModal,
selectedGroup,
selectedUsers,
fetchGroups,
handleCreateGroup,
handleEditGroup,
handleUpdateGroup,
handleManageMembers,
handleUserSelection,
handleDeleteGroup,
handleDeleteConfirm
} = useGroups()
// Wrap create/delete to emit `changed` for the parent view's counters.
const onCreateGroup = async (groupData: Partial<UserGroup>) => {
await handleCreateGroup(groupData)
emit('changed')
}
const onDeleteConfirm = async () => {
await handleDeleteConfirm()
emit('changed')
}
// Filter cards by group name (case-insensitive).
const filteredGroups = computed(() => {
const q = (props.searchQuery || '').trim().toLowerCase()
if (!q) return groups.value
return groups.value.filter(g => g.name.toLowerCase().includes(q))
})
const onlineCount = (group: UserGroup) =>
group.users?.filter(u => u.is_online).length || 0
const memberCount = (group: UserGroup) => group.users?.length || 0
// Up to ~4 avatars in the stack.
const stackedMembers = (group: UserGroup) => (group.users || []).slice(0, 4)
const initials = (user: User) => {
const name = (user.full_name || '').trim()
if (!name) return '?'
const parts = name.split(/\s+/)
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
}
// Deterministic accent per member, from the design palette tokens.
const avatarPalette = [
'var(--c-purple)',
'var(--c-coral)',
'var(--c-online)',
'var(--accent-ink)'
]
const avatarColor = (user: User) => {
let hash = 0
for (const ch of user.id || user.full_name || '') {
hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
}
return avatarPalette[hash % avatarPalette.length]
}
onMounted(fetchGroups)
</script>
<template>
<div class="group-list">
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="loading" class="loading">加载团队…</div>
<div v-else class="teams-grid">
<div v-for="group in filteredGroups" :key="group.id" class="team-card">
<div class="card-header">
<div class="team-name">{{ group.name }}</div>
<span class="online-badge" :class="{ active: onlineCount(group) > 0 }">
<span class="online-dot"></span>
<span class="online-text">{{ onlineCount(group) }} 人在线</span>
</span>
</div>
<p v-if="group.description" class="team-desc">{{ group.description }}</p>
<p v-else class="team-desc no-desc">暂无描述</p>
<div class="card-footer">
<div class="members">
<div class="avatar-stack">
<div
v-for="(member, i) in stackedMembers(group)"
:key="member.id"
class="avatar"
:style="{ background: avatarColor(member), marginLeft: i ? '-9px' : '0' }"
:title="member.full_name"
>
{{ initials(member) }}
</div>
</div>
<span class="member-count">
{{ memberCount(group) }} 位客服
</span>
</div>
<button class="manage-btn" @click="handleManageMembers(group)">管理</button>
</div>
<!-- Hidden edit/delete affordances preserved on the card via context actions -->
<div class="card-actions">
<button class="card-action" title="编辑团队" @click="handleEditGroup(group)">编辑</button>
<button class="card-action delete" title="删除团队" @click="handleDeleteGroup(group)">删除</button>
</div>
</div>
<!-- Create a team (dashed) card -->
<button class="create-card" @click="showCreateModal = true">
<span class="create-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</span>
<span class="create-title">创建团队</span>
<span class="create-sub">按技能或渠道给客服分组</span>
</button>
</div>
<!-- Create Group Modal -->
<Modal v-if="showCreateModal" @close="showCreateModal = false">
<template #title>创建团队</template>
<template #content>
<GroupForm
@submit="onCreateGroup"
@cancel="showCreateModal = false"
/>
</template>
</Modal>
<!-- Edit Group Modal -->
<Modal v-if="showEditModal" @close="showEditModal = false">
<template #title>编辑团队</template>
<template #content>
<GroupForm
:group="selectedGroup"
@submit="handleUpdateGroup"
@cancel="showEditModal = false"
/>
</template>
</Modal>
<!-- Manage Members Modal -->
<Modal v-if="showMembersModal" @close="showMembersModal = false">
<template #title>管理成员</template>
<template #content>
<div class="members-list">
<div v-for="user in users" :key="user.id" class="member-item">
<label class="checkbox-label">
<input
type="checkbox"
:checked="selectedUsers.includes(user.id)"
@change="handleUserSelection(user.id, ($event.target as HTMLInputElement).checked)"
:disabled="loading"
/>
<div class="user-info">
<span class="user-name">{{ user.full_name }}</span>
<span class="user-email">{{ user.email }}</span>
</div>
</label>
</div>
</div>
</template>
</Modal>
<!-- Delete Confirmation Modal -->
<Modal v-if="showDeleteModal" @close="showDeleteModal = false">
<template #title>删除团队</template>
<template #content>
<div class="delete-confirmation">
<p>确定要删除“{{ selectedGroup?.name }}”吗?</p>
<p class="warning">此操作无法撤销。</p>
<div class="form-actions">
<button class="btn btn-secondary" @click="showDeleteModal = false">
取消
</button>
<button class="btn btn-danger" @click="onDeleteConfirm">
删除
</button>
</div>
</div>
</template>
</Modal>
</div>
</template>
<style scoped>
.group-list {
width: 100%;
}
.teams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 18px;
}
/* ── Team card ───────────────────────────────────────── */
.team-card {
position: relative;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 18px;
padding: 22px;
display: flex;
flex-direction: column;
transition: border-color var(--transition-fast);
}
.team-card:hover {
border-color: var(--o14);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.team-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 17.5px;
letter-spacing: -0.01em;
color: var(--text);
}
.online-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--o05);
border: 1px solid var(--o10);
flex-shrink: 0;
}
.online-badge.active {
background: var(--teal-bg);
border: 1px solid var(--teal-border);
}
.online-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted);
}
.online-badge.active .online-dot {
background: var(--c-online);
box-shadow: 0 0 6px var(--c-online);
}
.online-text {
font-size: 11.5px;
font-weight: 500;
color: var(--muted);
}
.online-badge.active .online-text {
color: var(--c-online);
}
.team-desc {
font-size: 13.5px;
color: var(--muted);
line-height: 1.55;
margin: 12px 0 16px;
flex: 1;
}
.no-desc {
opacity: 0.7;
font-style: italic;
}
/* ── Footer ──────────────────────────────────────────── */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--o07);
}
.members {
display: flex;
align-items: center;
gap: 11px;
}
.avatar-stack {
display: flex;
}
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 11.5px;
color: var(--on-accent-solid);
border: 2px solid var(--surface);
}
.member-count {
font-size: 13px;
color: var(--muted);
}
.manage-btn {
padding: 9px 16px;
background: var(--o05);
border: 1px solid var(--o14);
border-radius: var(--radius-chip);
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
}
.manage-btn:hover {
background: var(--o10);
}
/* Edit / delete affordances revealed on hover */
.card-actions {
position: absolute;
top: 14px;
right: 14px;
display: flex;
gap: 6px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.team-card:hover .card-actions {
opacity: 1;
}
.card-action {
padding: 3px 9px;
font-size: 11.5px;
font-weight: 500;
border-radius: var(--radius-chip);
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.card-action:hover {
background: var(--o10);
color: var(--text);
}
.card-action.delete:hover {
color: var(--c-coral);
border-color: var(--c-coral);
}
/* ── Create a team card ──────────────────────────────── */
.create-card {
background: transparent;
border: 1.5px dashed var(--o16);
border-radius: 18px;
padding: 24px;
min-height: 210px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--muted2);
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast);
}
.create-card:hover {
border-color: var(--c-lime);
color: var(--c-lime);
}
.create-icon {
width: 46px;
height: 46px;
border-radius: 12px;
background: var(--o05);
display: flex;
align-items: center;
justify-content: center;
color: inherit;
}
.create-title {
font-size: 15px;
font-weight: 600;
color: var(--text2);
}
.create-sub {
font-size: 12.5px;
color: var(--muted2);
}
/* ── States ──────────────────────────────────────────── */
.error-message {
color: var(--error-color);
text-align: center;
padding: var(--space-lg);
}
.loading {
text-align: center;
padding: var(--space-xl);
opacity: 0.7;
}
/* ── Manage-members modal ────────────────────────────── */
.members-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-md) 0;
}
.member-item {
padding: var(--space-sm);
border-bottom: 1px solid var(--o08);
}
.member-item:last-child {
border-bottom: none;
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-md);
cursor: pointer;
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 500;
}
.user-email {
font-size: var(--text-sm);
opacity: 0.7;
}
/* ── Delete-confirm modal ────────────────────────────── */
.delete-confirmation {
text-align: center;
padding: var(--space-lg);
}
.delete-confirmation p {
margin-bottom: var(--space-md);
}
.warning {
color: var(--error-color);
font-size: var(--text-sm);
margin-top: var(--space-sm);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-md);
margin-top: var(--space-xl);
}
.btn-danger {
background: transparent;
border: 1px solid rgba(255, 138, 115, 0.3);
color: var(--c-coral);
}
.btn-danger:hover {
background: rgba(255, 138, 115, 0.1);
}
</style>
@@ -0,0 +1,181 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref } from 'vue'
import type { User } from '@/types/user'
import { meetsPasswordPolicy, validatePassword, type PasswordStrength } from '@/utils/validators'
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
const props = defineProps<{
user: User
submitting?: boolean
}>()
const emit = defineEmits<{
submit: [newPassword: string]
cancel: []
}>()
const password = ref('')
const confirmPassword = ref('')
const passwordTouched = ref(false)
const error = ref('')
const passwordStrength = ref<PasswordStrength>({
score: 0,
hasMinLength: false,
hasUpperCase: false,
hasLowerCase: false,
hasNumber: false,
hasSpecialChar: false,
})
const handlePasswordInput = () => {
passwordTouched.value = passwordTouched.value || password.value.length > 0
passwordStrength.value = validatePassword(password.value)
error.value = ''
}
const handleConfirmPasswordInput = () => {
error.value =
confirmPassword.value && password.value !== confirmPassword.value
? '两次输入的密码不一致'
: ''
}
const handleSubmit = () => {
if (password.value !== confirmPassword.value) {
error.value = '两次输入的密码不一致'
return
}
if (!meetsPasswordPolicy(passwordStrength.value)) {
error.value = '密码强度不足'
return
}
emit('submit', password.value)
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="reset-password-form">
<p class="intro">
为 <strong>{{ props.user.full_name }}</strong>
({{ props.user.email }})设置新密码。对方不会收到通知——请直接转告,
并请其在登录后自行修改。
</p>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="form-group">
<label for="newPassword">新密码</label>
<input
id="newPassword"
v-model="password"
type="password"
required
class="form-input"
autocomplete="new-password"
@input="handlePasswordInput"
/>
<PasswordStrengthMeter v-if="passwordTouched" :strength="passwordStrength" />
</div>
<div class="form-group">
<label for="confirmNewPassword">确认新密码</label>
<input
id="confirmNewPassword"
v-model="confirmPassword"
type="password"
required
class="form-input"
autocomplete="new-password"
:class="{ error: error.includes('不一致') }"
@input="handleConfirmPasswordInput"
/>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
取消
</button>
<button type="submit" class="btn btn-primary" :disabled="props.submitting">
{{ props.submitting ? '重置中…' : '重置密码' }}
</button>
</div>
</form>
</template>
<style scoped>
.reset-password-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.intro {
margin: 0;
font-size: 13.5px;
line-height: 1.6;
color: var(--text3);
}
/* Matches UserForm: the global .form-group margin plus the flex gap above
would otherwise double the spacing between fields. */
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 0;
}
.form-group label {
font-size: 13.5px;
font-weight: var(--font-weight-medium);
color: var(--text3);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.error-message {
color: var(--error-color);
font-size: var(--text-sm);
padding: var(--space-sm);
background: var(--error-soft);
border-radius: var(--radius-sm);
}
.form-input.error {
border-color: var(--error-color);
}
.form-input.error:focus {
box-shadow: 0 0 0 2px var(--error-soft);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,298 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { Role } from '@/types/user'
import { listPermissions, type Permission } from '@/services/roles'
const props = defineProps<{
role?: Role | null
}>()
const emit = defineEmits<{
submit: [roleData: Partial<Role>]
cancel: []
}>()
const name = ref('')
const description = ref('')
const selectedPermissions = ref<Permission[]>([])
const permissions = ref<Permission[]>([])
const loading = ref(false)
const error = ref('')
// Watch for role prop changes to update form
watch(() => props.role, (newRole) => {
name.value = newRole?.name || ''
description.value = newRole?.description || ''
selectedPermissions.value = newRole?.permissions || []
}, { immediate: true })
const fetchPermissions = async () => {
try {
loading.value = true
permissions.value = await listPermissions()
} catch (err) {
console.error('Failed to load permissions:', err)
error.value = '加载权限失败'
} finally {
loading.value = false
}
}
const handleSubmit = () => {
if (!selectedPermissions.value.length) {
error.value = '请至少选择一项权限'
return
}
emit('submit', {
name: name.value,
description: description.value,
is_default: false,
permissions: selectedPermissions.value
})
// Reset form if not editing
if (!props.role) {
name.value = ''
description.value = ''
selectedPermissions.value = []
error.value = ''
}
}
onMounted(fetchPermissions)
</script>
<template>
<form @submit.prevent="handleSubmit" class="role-form">
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="form-group">
<label for="name">名称</label>
<input
id="name"
v-model="name"
type="text"
required
class="form-input"
/>
</div>
<div class="form-group">
<label for="description">描述</label>
<textarea
id="description"
v-model="description"
class="form-input"
rows="3"
/>
</div>
<div class="form-group">
<label>权限</label>
<div v-if="loading" class="loading">加载权限…</div>
<div v-else class="permissions-list">
<label
v-for="permission in permissions"
:key="permission.id"
class="permission-item"
>
<input
type="checkbox"
:checked="selectedPermissions.map(p => p.id).includes(permission.id)"
@change="($event) => {
if (($event.target as HTMLInputElement).checked) {
selectedPermissions.push(permission)
} else {
selectedPermissions = selectedPermissions.filter(p => p.id !== permission.id)
}
}"
/>
<div class="permission-info">
<span class="permission-name">{{ permission.name }}</span>
<span v-if="permission.description" class="permission-description">
{{ permission.description }}
</span>
</div>
</label>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
取消
</button>
<button type="submit" class="btn btn-primary">
{{ props.role ? '更新' : '创建' }}
</button>
</div>
</form>
</template>
<style scoped>
.role-form {
padding: 0;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 9px;
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
color: var(--text);
}
.form-input {
width: 100%;
box-sizing: border-box;
padding: 13px 15px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: var(--radius-input);
color: var(--text);
font-family: var(--font-sans);
font-size: 14.5px;
}
textarea.form-input {
resize: vertical;
line-height: 1.5;
}
.form-input::placeholder {
color: var(--faint);
}
.form-input:focus {
outline: none;
border-color: var(--accent-ink);
box-shadow: var(--ring-focus);
}
.permissions-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--o10);
border-radius: var(--radius-input);
padding: 10px;
}
.permission-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 9px 11px;
border-radius: 9px;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.permission-item:hover {
background: var(--o04);
}
.permission-item input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 1px 0 0;
flex-shrink: 0;
accent-color: var(--accent-ink);
cursor: pointer;
}
.permission-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.permission-name {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--text2);
word-break: break-word;
}
.permission-description {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
line-height: 1.35;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 22px;
}
.btn {
padding: 12px 22px;
border-radius: var(--radius-btn);
font-family: var(--font-sans);
font-size: 14.5px;
font-weight: 600;
cursor: pointer;
transition: background-color var(--transition-fast), filter var(--transition-fast);
}
.btn-primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
}
.btn-primary:hover {
filter: brightness(1.05);
}
.btn-secondary {
background: var(--o05);
border: 1px solid var(--o14);
color: var(--text);
}
.btn-secondary:hover {
background: var(--o10);
}
.error-message {
color: var(--error-color);
margin-bottom: var(--space-md);
font-size: 14px;
}
.loading {
text-align: center;
padding: var(--space-lg);
color: var(--muted);
}
</style>
@@ -0,0 +1,731 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import Modal from '@/components/common/Modal.vue'
import RoleForm from './RoleForm.vue'
import type { Role } from '@/types/user'
import { useRoles } from '@/composables/useRoles'
import { useSubscriptionStorage } from '@/utils/storage'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
const props = defineProps<{
searchQuery?: string
}>()
const emit = defineEmits<{
(e: 'changed'): void
}>()
const {
roles,
loading,
error,
showCreateModal,
showEditModal,
showDeleteModal,
selectedRole,
fetchRoles,
handleCreateRole,
handleEditRole,
handleUpdateRole,
handleDeleteRole,
confirmDeleteRole,
openCreateModal,
deleteError
} = useRoles()
// Wrap create/delete so the parent view can refresh counts after a change
const createRoleAndNotify = async (data: Partial<Role>) => {
await handleCreateRole(data)
if (!showCreateModal.value) {
emit('changed')
}
}
const deleteRoleAndNotify = async () => {
await confirmDeleteRole()
if (!showDeleteModal.value) {
emit('changed')
}
}
// Filter cards by role name (case-insensitive)
const filteredRoles = computed(() => {
const q = (props.searchQuery || '').trim().toLowerCase()
if (!q) return roles.value
return roles.value.filter(role => role.name?.toLowerCase().includes(q))
})
// Subscription and role feature checking
const subscriptionStorage = useSubscriptionStorage()
const { hasEnterpriseModule } = useEnterpriseFeatures()
const currentSubscription = computed(() => subscriptionStorage.getCurrentSubscription())
const isSubscriptionActive = computed(() => subscriptionStorage.isSubscriptionActive())
// Check if roles is locked (only if enterprise module exists)
const isRolesLocked = computed(() => {
// Only lock if enterprise module exists
if (!hasEnterpriseModule) {
return false
}
if (!currentSubscription.value || !isSubscriptionActive.value) {
return true
}
// Check if role feature exists in subscription
const hasRoleFeature = subscriptionStorage.hasFeature('role')
if (!hasRoleFeature) {
return true // Lock roles if feature doesn't exist
}
return false
})
// Upgrade modal functions
const handleUpgrade = () => {
// Only redirect to subscription page if enterprise module exists
if (hasEnterpriseModule) {
window.location.href = '/settings/subscription'
}
}
onMounted(() => {
if (!isRolesLocked.value) {
fetchRoles()
}
})
</script>
<template>
<div class="role-list">
<!-- Roles Locked Overlay -->
<div v-if="isRolesLocked" class="roles-locked-overlay">
<div class="locked-content">
<div class="locked-header">
<div class="locked-icon-wrapper">
<div class="locked-icon-bg">
<font-awesome-icon icon="fa-solid fa-users-gear" class="locked-icon" />
</div>
</div>
<h2>角色管理</h2>
<div class="locked-badge">
<font-awesome-icon icon="fa-solid fa-lock" class="badge-icon" />
<span>高级功能</span>
</div>
</div>
<p class="locked-description">
解锁高级角色管理,掌控用户权限,
创建自定义角色,并管理团队访问级别。
</p>
<div class="locked-features">
<div class="feature-item">
<div class="feature-icon-wrapper">
<font-awesome-icon icon="fa-solid fa-user-shield" class="feature-icon" />
</div>
<div class="feature-content">
<span class="feature-title">自定义角色</span>
<span class="feature-desc">创建并管理自定义用户角色</span>
</div>
</div>
<div class="feature-item">
<div class="feature-icon-wrapper">
<font-awesome-icon icon="fa-solid fa-key" class="feature-icon" />
</div>
<div class="feature-content">
<span class="feature-title">权限控制</span>
<span class="feature-desc">精细化的访问权限管理</span>
</div>
</div>
</div>
<div class="upgrade-section">
<button class="upgrade-button" @click="handleUpgrade">
<font-awesome-icon icon="fa-solid fa-crown" class="upgrade-icon" />
<span>升级以解锁角色</span>
<font-awesome-icon icon="fa-solid fa-arrow-right" class="arrow-icon" />
</button>
</div>
</div>
</div>
<!-- Roles Content (when unlocked) -->
<div v-else>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="loading" class="loading">加载角色…</div>
<div v-else class="roles-grid">
<div
v-for="role in filteredRoles"
:key="role.id"
class="role-card"
>
<div class="role-card__header">
<h3 class="role-card__name">{{ role.name }}</h3>
<span v-if="role.is_default" class="role-card__badge">默认</span>
</div>
<p class="role-card__desc" :class="{ 'is-muted': !role.description }">
{{ role.description || '暂无描述' }}
</p>
<template v-if="role.permissions?.length">
<div class="role-card__perm-label">权限</div>
<div class="role-card__perm-chips">
<span
v-for="permission in role.permissions"
:key="permission.id"
class="role-card__chip"
>
{{ permission.name }}
</span>
</div>
</template>
<button
class="role-card__edit"
@click="handleEditRole(role)"
:disabled="role.is_default"
:title="role.is_default ? '无法编辑默认角色' : ''"
>
编辑权限
</button>
<button
v-if="!role.is_default"
class="role-card__delete"
@click="handleDeleteRole(role)"
>
删除角色
</button>
</div>
<!-- Create a custom role card -->
<button class="role-card-add" @click="openCreateModal">
<div class="role-card-add__icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</div>
<div class="role-card-add__title">创建自定义角色</div>
<div class="role-card-add__desc">精确指定一组客服可获得的权限</div>
</button>
</div>
<!-- Create/Edit Role Modal -->
<Modal v-if="showCreateModal || showEditModal"
@close="showCreateModal ? (showCreateModal = false) : (showEditModal = false)">
<template #title>{{ showCreateModal ? '创建角色' : '编辑角色' }}</template>
<template #content>
<RoleForm
:role="selectedRole"
@submit="(data) => showCreateModal ? createRoleAndNotify(data) : handleUpdateRole(data)"
@cancel="showCreateModal ? (showCreateModal = false) : (showEditModal = false)"
/>
</template>
</Modal>
<!-- Delete Confirmation Modal -->
<Modal v-if="showDeleteModal" @close="showDeleteModal = false">
<template #title>删除角色</template>
<template #content>
<div class="delete-confirmation">
<div v-if="deleteError" class="error-message">
{{ deleteError }}
</div>
<p>确定要删除“{{ selectedRole?.name }}”吗?</p>
<p class="warning">此操作无法撤销。</p>
<div class="form-actions">
<button class="btn btn-secondary" @click="showDeleteModal = false">
取消
</button>
<button
class="btn btn-danger"
@click="deleteRoleAndNotify"
:disabled="loading"
>
{{ loading ? '删除中…' : '删除' }}
</button>
</div>
</div>
</template>
</Modal>
</div>
</div>
</template>
<style scoped>
.role-list {
padding: var(--space-lg);
}
.roles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 18px;
align-items: start;
}
.role-card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 18px;
padding: 24px;
display: flex;
flex-direction: column;
transition: border-color var(--transition-fast);
}
.role-card:hover {
border-color: var(--o16);
}
.role-card__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.role-card__name {
font-family: var(--font-display);
font-weight: 600;
font-size: 19px;
color: var(--text);
margin: 0;
}
.role-card__badge {
padding: 2px 9px;
border-radius: var(--radius-pill);
background: var(--accent-bg-12);
border: 1px solid var(--accent-border);
color: var(--accent-ink);
font-size: 11px;
font-weight: 500;
}
.role-card__desc {
font-size: 13.5px;
color: var(--muted);
line-height: 1.5;
margin: 0 0 18px;
}
.role-card__desc.is-muted {
color: var(--muted2);
}
.role-card__perm-label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.06em;
color: var(--faint);
margin-bottom: 9px;
}
.role-card__perm-chips {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.role-card__chip {
font-family: var(--font-mono);
font-size: 11.5px;
padding: 4px 9px;
border-radius: 7px;
background: var(--o05);
border: 1px solid var(--o08);
color: var(--text3);
white-space: nowrap;
}
.role-card__edit {
margin-top: 20px;
padding: 11px;
background: var(--o05);
border: 1px solid var(--o14);
border-radius: var(--radius-chip);
color: var(--text);
font-size: 13.5px;
font-weight: 500;
font-family: var(--font-sans);
cursor: pointer;
transition: background var(--transition-fast);
}
.role-card__edit:hover:not(:disabled) {
background: var(--o10);
}
.role-card__edit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.role-card__delete {
margin-top: 8px;
padding: 8px;
background: transparent;
border: none;
color: var(--muted2);
font-size: 12.5px;
font-weight: 500;
font-family: var(--font-sans);
cursor: pointer;
transition: color var(--transition-fast);
}
.role-card__delete:hover {
color: var(--c-coral);
}
/* Create a custom role dashed card */
.role-card-add {
background: transparent;
border: 1.5px dashed var(--o16);
border-radius: 18px;
padding: 24px;
min-height: 240px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--muted2);
font-family: var(--font-sans);
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast);
}
.role-card-add:hover {
border-color: var(--accent-border);
color: var(--accent-ink);
}
.role-card-add__icon {
width: 46px;
height: 46px;
border-radius: 12px;
background: var(--o05);
display: flex;
align-items: center;
justify-content: center;
}
.role-card-add__title {
font-size: 15px;
font-weight: 600;
color: var(--text2);
}
.role-card-add__desc {
font-size: 12.5px;
color: var(--muted2);
text-align: center;
max-width: 200px;
}
.loading {
text-align: center;
padding: var(--space-xl);
opacity: 0.7;
}
.error-message {
color: var(--c-coral);
margin-bottom: var(--space-lg);
}
.delete-confirmation {
text-align: center;
padding: var(--space-lg);
}
.delete-confirmation p {
margin-bottom: var(--space-md);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-md);
margin-top: var(--space-xl);
}
/* Roles Locked Overlay Styles */
.roles-locked-overlay {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
background: var(--surface);
border-radius: 20px;
margin: var(--space-md) 0;
position: relative;
overflow: hidden;
border: 1px solid var(--o08);
}
.roles-locked-overlay::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(201, 242, 78, 0.04) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(16, 185, 129, 0.05) 0%, transparent 50%);
pointer-events: none;
}
.locked-content {
text-align: center;
max-width: 700px;
padding: var(--space-xl) var(--space-md);
position: relative;
z-index: 1;
}
.locked-header {
margin-bottom: var(--space-lg);
}
.locked-icon-wrapper {
margin-bottom: var(--space-md);
}
.locked-icon-bg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
background: rgba(201, 242, 78, 0.15);
border: 1px solid rgba(201, 242, 78, 0.3);
border-radius: 50%;
box-shadow: var(--shadow-lg);
margin-bottom: var(--space-sm);
}
.locked-icon {
font-size: 1.5rem;
color: var(--accent-ink);
}
.locked-content h2 {
font-size: var(--text-3xl);
font-weight: 700;
font-family: var(--font-display);
color: var(--text);
margin-bottom: var(--space-sm);
}
.locked-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
background: rgba(201, 242, 78, 0.12);
color: var(--accent-ink);
border: 1px solid rgba(201, 242, 78, 0.3);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
}
.badge-icon {
font-size: 0.75rem;
}
.locked-description {
font-size: var(--text-lg);
color: var(--muted);
line-height: 1.6;
margin-bottom: var(--space-lg);
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.locked-features {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.feature-item {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-lg);
background: var(--o05);
border-radius: 14px;
border: 1px solid var(--o10);
text-align: left;
transition: all var(--transition-normal);
}
.feature-item:hover {
transform: translateY(-2px);
background: var(--o08);
border-color: var(--o16);
}
.feature-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: rgba(201, 242, 78, 0.12);
border-radius: var(--radius-md);
flex-shrink: 0;
}
.feature-icon {
font-size: 1rem;
color: var(--accent-ink);
}
.feature-content {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.feature-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--text);
}
.feature-desc {
font-size: var(--text-sm);
color: var(--muted);
line-height: 1.4;
}
.upgrade-section {
text-align: center;
}
.upgrade-button {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 14px;
padding: var(--space-md) var(--space-xl);
font-size: var(--text-base);
font-weight: 700;
cursor: pointer;
transition: all var(--transition-normal);
position: relative;
overflow: hidden;
}
.upgrade-button:hover {
opacity: 0.88;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(201, 242, 78, 0.25);
}
.upgrade-icon {
font-size: 1rem;
color: var(--on-accent-solid);
}
.arrow-icon {
font-size: 0.875rem;
transition: transform var(--transition-normal);
}
.upgrade-button:hover .arrow-icon {
transform: translateX(4px);
}
/* Responsive adjustments for locked overlay */
@media (max-width: 768px) {
.roles-locked-overlay {
min-height: 50vh;
margin: var(--space-md) 0;
}
.locked-content {
padding: var(--space-xl) var(--space-md);
}
.locked-content h2 {
font-size: var(--text-2xl);
}
.locked-description {
font-size: var(--text-base);
margin-bottom: var(--space-lg);
}
.locked-features {
grid-template-columns: 1fr;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.feature-item {
padding: var(--space-md);
}
.feature-icon-wrapper {
width: 32px;
height: 32px;
}
.feature-icon {
font-size: 0.875rem;
}
.upgrade-button {
width: 100%;
padding: var(--space-md) var(--space-lg);
font-size: var(--text-sm);
}
.locked-icon-bg {
width: 48px;
height: 48px;
}
.locked-icon {
font-size: 1.25rem;
}
.locked-header {
margin-bottom: var(--space-lg);
}
}
</style>
@@ -0,0 +1,367 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue'
import type { ChatScopeFields, Role, User } from '@/types/user'
import { meetsPasswordPolicy, validatePassword, type PasswordStrength } from '@/utils/validators'
import { listRoles } from '@/services/roles'
import { AI_QUEUE_PERMISSION, ALL_CHATS_PERMISSION } from '@/utils/permissionGroups'
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
import { toast } from 'vue-sonner'
const props = defineProps<{
user?: User | null
}>()
const emit = defineEmits<{
submit: [userData: Partial<User> & ChatScopeFields & { password?: string, role_id?: number }]
cancel: []
}>()
const fullName = ref(props.user?.full_name || '')
const email = ref(props.user?.email || '')
const isActive = ref(props.user?.is_active ?? true)
const password = ref('')
const confirmPassword = ref('')
const showPasswordFields = ref(!props.user)
const passwordTouched = ref(false)
const error = ref('')
const roles = ref<Role[]>([])
const selectedRole = ref(props.user?.role?.id || '')
const loadingRoles = ref(false)
// Chat scope. A new agent starts able to pick up whatever the AI is still
// handling, and unable to read colleagues' conversations — tick the second one
// to let someone cover a teammate's inbox. Once roles load these re-derive from
// whichever role is selected, so they always show what that role actually does.
const seeAllAiChats = ref(true)
const seeAllOrgChats = ref(false)
const selectedRoleObject = computed(
() => roles.value.find(role => String(role.id) === String(selectedRole.value))
)
const permissionsOfSelectedRole = computed(
() => selectedRoleObject.value?.permissions?.map(permission => permission.name) ?? []
)
// super_admin passes every check, so the toggles would be decorative.
const scopeApplies = computed(
() => !permissionsOfSelectedRole.value.includes('super_admin')
)
const syncScopeFromRole = () => {
// Only bail when the role itself is missing — still loading, or nothing
// picked yet. A role that exists and grants nothing should untick both,
// which testing for an empty permission list would get wrong.
if (!selectedRoleObject.value) return
const permissions = permissionsOfSelectedRole.value
seeAllAiChats.value = permissions.includes(AI_QUEUE_PERMISSION)
seeAllOrgChats.value = permissions.includes(ALL_CHATS_PERMISSION)
}
watch(selectedRole, syncScopeFromRole)
const passwordStrength = ref<PasswordStrength>({
score: 0,
hasMinLength: false,
hasUpperCase: false,
hasLowerCase: false,
hasNumber: false,
hasSpecialChar: false
})
const handlePasswordInput = (password: string) => {
if (!passwordTouched.value && password.length > 0) {
passwordTouched.value = true
}
passwordStrength.value = validatePassword(password)
error.value = ''
}
const handleConfirmPasswordInput = () => {
if (confirmPassword.value && password.value !== confirmPassword.value) {
error.value = '两次输入的密码不一致'
} else {
error.value = ''
}
}
const fetchRoles = async () => {
try {
loadingRoles.value = true
roles.value = await listRoles()
syncScopeFromRole()
} catch (err) {
console.error('Error loading roles:', err)
toast.error('错误', {
description: '加载角色失败',
duration: 4000,
closeButton: true
})
} finally {
loadingRoles.value = false
}
}
onMounted(fetchRoles)
const handleSubmit = () => {
if (showPasswordFields.value) {
if (password.value !== confirmPassword.value) {
error.value = '两次输入的密码不一致'
return
}
// The initial password an admin picks clears the same bar as a reset —
// three surfaces used to disagree on what "strong enough" meant.
if (!meetsPasswordPolicy(passwordStrength.value)) {
error.value = '密码强度不足'
return
}
}
if (!selectedRole.value) {
error.value = '请选择角色'
return
}
const userData = {
full_name: fullName.value,
email: email.value,
is_active: isActive.value,
role_id: Number(selectedRole.value),
...(scopeApplies.value && {
see_all_ai_chats: seeAllAiChats.value,
see_all_org_chats: seeAllOrgChats.value
}),
...(showPasswordFields.value && { password: password.value })
}
emit('submit', userData)
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="user-form">
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="form-group">
<label for="fullName">姓名</label>
<input
id="fullName"
v-model="fullName"
type="text"
required
class="form-input"
/>
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input
id="email"
v-model="email"
type="email"
required
class="form-input"
/>
</div>
<div class="form-group">
<label for="role">角色</label>
<select
id="role"
v-model="selectedRole"
required
class="form-input"
:disabled="loadingRoles"
>
<option value="" disabled>选择角色</option>
<option
v-for="role in roles"
:key="role.id"
:value="role.id"
>
{{ role.name }}
</option>
</select>
<span v-if="loadingRoles" class="loading-text">加载角色…</span>
</div>
<div v-if="scopeApplies" class="form-group">
<label>可以查看哪些会话?</label>
<label class="checkbox-label">
<input type="checkbox" v-model="seeAllAiChats" />
<span>
全部 AI 会话
<small>AI 正在处理、尚无人接手的会话。</small>
</span>
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="seeAllOrgChats" />
<span>
组织内全部会话
<small>包含其他人的会话——适合在同事休假时代为处理。</small>
</span>
</label>
</div>
<template v-if="showPasswordFields">
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="password"
type="password"
required
class="form-input"
autocomplete="new-password"
@input="handlePasswordInput(password)"
/>
<PasswordStrengthMeter v-if="passwordTouched" :strength="passwordStrength" />
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
required
class="form-input"
autocomplete="new-password"
@input="handleConfirmPasswordInput"
:class="{ 'error': error && error.includes('不一致') }"
/>
</div>
</template>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
v-model="isActive"
/>
启用账号
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
取消
</button>
<button type="submit" class="btn btn-primary">
{{ props.user ? '更新' : '创建' }}
</button>
</div>
</form>
</template>
<style scoped>
.user-form {
display: flex;
flex-direction: column;
gap: 18px;
}
/* The global .form-group already adds margin-bottom: var(--space-lg); combined
with the flex gap above that doubled the spacing between every field. Zero
it and let the single flex gap own the vertical rhythm. */
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 0;
}
.form-group label {
font-size: 13.5px;
font-weight: var(--font-weight-medium);
color: var(--text3);
}
/* Inputs deliberately inherit the app-wide .form-input styling from
components.css (padding, radius, hover + focus ring) so they match every
other form — only the <select> chevron is added below. */
.checkbox-label {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
font-size: var(--text-sm);
color: var(--text);
}
.checkbox-label input[type="checkbox"] {
margin-top: 2px;
flex-shrink: 0;
}
.checkbox-label small {
display: block;
color: var(--text3);
font-size: var(--text-xs);
margin-top: 2px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.error-message {
color: var(--error-color);
font-size: var(--text-sm);
margin-bottom: var(--space-md);
padding: var(--space-sm);
background: var(--error-soft);
border-radius: var(--radius-sm);
}
.form-input.error {
border-color: var(--error-color);
}
.form-input.error:focus {
box-shadow: 0 0 0 2px var(--error-soft);
}
.loading-text {
font-size: var(--text-sm);
color: var(--text-color);
opacity: 0.7;
margin-top: var(--space-xs);
}
select.form-input {
appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
}
select.form-input:disabled {
background-color: var(--background-mute);
cursor: not-allowed;
}
</style>
@@ -0,0 +1,117 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { User } from '@/types/user'
import { listGroups } from '@/services/groups'
import type { UserGroup } from '@/types/user'
const props = defineProps<{
user: User
}>()
const emit = defineEmits<{
close: []
groupToggle: [groupId: string, checked: boolean]
}>()
const groups = ref<UserGroup[]>([])
const loading = ref(false)
const selectedGroups = ref<string[]>(props.user.groups?.map(g => g.id) || [])
const fetchGroups = async () => {
try {
loading.value = true
groups.value = await listGroups()
} catch (err) {
console.error('Failed to load groups:', err)
} finally {
loading.value = false
}
}
onMounted(fetchGroups)
</script>
<template>
<div class="groups-form">
<div v-if="loading" class="loading">加载团队…</div>
<div v-else class="groups-list">
<div v-for="group in groups" :key="group.id" class="group-item">
<label class="checkbox-label">
<input
type="checkbox"
:checked="selectedGroups.includes(group.id)"
@change="$emit('groupToggle', group.id, ($event.target as HTMLInputElement).checked)"
:disabled="loading"
/>
<div class="group-info">
<span class="group-name">{{ group.name }}</span>
<span v-if="group.description" class="group-description">{{ group.description }}</span>
</div>
</label>
</div>
</div>
</div>
</template>
<style scoped>
.groups-form {
padding: var(--space-md);
}
.groups-list {
max-height: 400px;
overflow-y: auto;
}
.group-item {
padding: var(--space-sm);
border-bottom: 1px solid var(--border-color);
}
.group-item:last-child {
border-bottom: none;
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-md);
cursor: pointer;
}
.group-info {
display: flex;
flex-direction: column;
}
.group-name {
font-weight: 500;
}
.group-description {
font-size: var(--text-sm);
opacity: 0.7;
}
.loading {
text-align: center;
padding: var(--space-xl);
opacity: 0.7;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,352 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts" name="AppSidebar">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useNavItems, navIconSvg } from './navItems'
import SidebarToggle from './SidebarToggle.vue'
defineProps<{
isCollapsed: boolean
}>()
const emit = defineEmits<{
(e: 'toggle'): void
(e: 'navigate'): void
}>()
const route = useRoute()
const { navItems } = useNavItems()
const isActiveRoute = computed(() => (path?: string) => path ? route.path === path : false)
const handleNavigation = () => {
emit('navigate')
}
</script>
<template>
<aside class="sidebar" :class="{ 'collapsed': isCollapsed }">
<!-- Logo -->
<div class="sidebar-header">
<div class="logo-container">
<div class="logo-mark" aria-hidden="true">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<span v-if="!isCollapsed" class="logo-text">ChatterMate</span>
</div>
<SidebarToggle :isCollapsed="isCollapsed" @toggle="emit('toggle')" />
</div>
<!-- Navigation -->
<nav class="sidebar-nav">
<div v-for="(item, index) in navItems" :key="index">
<!-- Section Header -->
<div v-if="item.section" class="nav-section nav-section-heading" :class="{ 'collapsed': isCollapsed }">
<span v-if="!isCollapsed">{{ item.section }}</span>
</div>
<!-- Nav Item -->
<router-link v-else-if="item.to" :to="item.to" class="nav-item"
:class="{ 'active': isActiveRoute(item.to) }"
:title="isCollapsed ? item.label : undefined"
@click="handleNavigation">
<span class="nav-bar" aria-hidden="true"></span>
<span class="nav-icon" v-html="navIconSvg(item.icon)"></span>
<span v-if="!isCollapsed" class="nav-label">{{ item.label }}</span>
</router-link>
</div>
</nav>
</aside>
</template>
<style scoped>
.sidebar {
width: 252px;
background: var(--bg2);
border-right: 1px solid var(--o07);
display: flex;
flex-direction: column;
transition: width .22s ease;
overflow: hidden;
position: relative;
height: 100vh;
z-index: 100;
}
.sidebar.collapsed {
width: 76px;
}
.sidebar-header {
padding: 20px 14px;
border-bottom: 1px solid var(--o06);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Collapsed header: stack logo above the toggle, both centered */
.sidebar.collapsed .sidebar-header {
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 18px 0;
}
.logo-container {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
/* 3-dot logo mark */
.logo-mark {
width: 32px;
height: 32px;
background: var(--accent-solid);
border-radius: 10px 10px 10px 2px;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
flex-shrink: 0;
}
.dot {
width: 4.5px;
height: 4.5px;
background: var(--on-accent);
border-radius: 50%;
}
.logo-text {
font-family: var(--font-display);
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-display);
font-size: 18px;
color: var(--text);
white-space: nowrap;
flex-shrink: 0;
}
.sidebar-nav {
flex: 1;
padding: 18px 14px;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
/* Hide the scrollbar so it doesn't squeeze the centered icons when collapsed */
scrollbar-width: none;
-ms-overflow-style: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
}
/* Collapsed: tighten side padding so icons sit dead-centre in the 76px rail */
.sidebar.collapsed .sidebar-nav {
padding: 18px 8px;
}
/* Typography comes from the shared .nav-section-heading (components.css) */
.nav-section {
padding: 0 13px;
margin: 4px 0 12px;
}
/* Collapsed: section label becomes an invisible spacer, no divider line */
.nav-section.collapsed {
height: 16px;
margin: 0;
padding: 0;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 13px;
padding: 11px 13px;
margin-bottom: 4px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 14.5px;
font-weight: var(--font-weight-medium);
text-decoration: none;
border-radius: var(--radius-btn);
transition: background-color var(--transition-fast), color var(--transition-fast);
}
/* Collapsed: center the icon, drop the gap */
.sidebar.collapsed .nav-item {
padding: 11px;
gap: 0;
justify-content: center;
}
.nav-item:hover:not(.active) {
background: var(--o04);
color: var(--text2);
}
.nav-item:focus-visible {
outline: none;
box-shadow: var(--ring-focus);
}
.nav-item.active {
background: var(--accent-bg-12);
color: var(--accent-ink);
font-weight: var(--font-weight-semibold);
}
/* Lime accent bar on the active item */
.nav-bar {
position: absolute;
left: 0;
top: 9px;
bottom: 9px;
width: 3px;
border-radius: 3px;
background: var(--accent-solid);
opacity: 0;
}
.nav-item.active .nav-bar {
opacity: 1;
}
/* Hide the active bar when collapsed */
.sidebar.collapsed .nav-item.active .nav-bar {
opacity: 0;
}
.nav-icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: inherit;
}
.nav-icon :deep(svg) {
width: 19px;
height: 19px;
display: block;
}
.nav-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Small laptops (1025px - 1280px) */
@media (max-width: 1280px) and (min-width: 1025px) {
.sidebar {
width: 248px;
position: relative;
}
.sidebar.collapsed {
width: 76px;
}
}
/* Tablets and below - Overlay mode */
@media (max-width: 1024px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
box-shadow: 8px 0 32px rgba(0,0,0,.4);
transform: translateX(0);
}
.sidebar.collapsed {
transform: translateX(-100%);
width: 252px;
/* An off-canvas panel must not keep painting its shadow over the page.
Sliding it out moves the box, not the shadow's reach: with an 8px
offset and 32px blur, roughly 24px of it still lands on the page's
left edge, above the content at z-index 1000. On the dark theme that
is black on near-black and invisible, which is why it went unnoticed;
on the light theme it reads as a grey band down the side. */
box-shadow: none;
}
/* In overlay mode the panel is full-width — restore expanded layout */
.sidebar.collapsed .sidebar-header {
flex-direction: row;
justify-content: space-between;
padding: 22px 20px;
}
.sidebar.collapsed .nav-item {
padding: 11px 13px;
gap: 13px;
justify-content: flex-start;
}
}
/* Mobile */
@media (max-width: 768px) {
.sidebar {
width: 280px;
max-width: 85vw;
}
.sidebar.collapsed {
width: 280px;
max-width: 85vw;
}
}
/* Very small mobile */
@media (max-width: 480px) {
.sidebar {
width: 100%;
max-width: 100vw;
}
.sidebar.collapsed {
width: 100%;
max-width: 100vw;
}
.nav-item {
padding: var(--space-md) var(--space-lg);
}
.nav-section {
padding: var(--space-md) var(--space-lg) var(--space-xs);
}
}
</style>
@@ -0,0 +1,156 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts" name="BottomNav">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useNavItems, navIconSvg, formatBadgeCount } from './navItems'
const props = defineProps<{
unreadCount?: number
moreOpen?: boolean
}>()
const emit = defineEmits<{
(e: 'more'): void
}>()
const route = useRoute()
const { primaryNavItems, moreNavItems } = useNavItems()
const isActive = (path?: string) => !!path && route.path.startsWith(path)
// More is "active" while its sheet is open or when the current page lives in it
const moreActive = computed(() =>
props.moreOpen || moreNavItems.value.some(item => isActive(item.to))
)
const badgeText = computed(() => formatBadgeCount(props.unreadCount))
</script>
<template>
<nav class="bottom-nav" aria-label="主导航">
<router-link
v-for="item in primaryNavItems"
:key="item.to"
:to="item.to!"
class="bottom-nav-item"
:class="{ active: isActive(item.to) }"
>
<span class="bottom-nav-icon" v-html="navIconSvg(item.icon, 24)"></span>
<span class="bottom-nav-label">{{ item.label }}</span>
</router-link>
<button
type="button"
class="bottom-nav-item"
:class="{ active: moreActive }"
aria-label="更多"
@click="emit('more')"
>
<span class="bottom-nav-icon" v-html="navIconSvg('more', 24)"></span>
<span class="bottom-nav-label">更多</span>
<span v-if="unreadCount" class="bottom-nav-badge">{{ badgeText }}</span>
</button>
</nav>
</template>
<style scoped>
.bottom-nav {
display: none;
}
@media (max-width: 768px) {
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-bottom-nav);
display: flex;
justify-content: space-between;
background: var(--bg2);
border-top: 1px solid var(--o07);
padding: 6px 14px calc(6px + var(--safe-bottom));
}
}
.bottom-nav-item {
position: relative;
flex: 1;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 2px 0;
background: none;
border: none;
color: var(--muted);
text-decoration: none;
font-family: var(--font-sans);
cursor: pointer;
transition: color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
}
.bottom-nav-item.active {
color: var(--accent-ink);
}
.bottom-nav-icon {
display: flex;
align-items: center;
justify-content: center;
}
.bottom-nav-icon :deep(svg) {
width: 24px;
height: 24px;
display: block;
stroke-width: 1.8;
}
.bottom-nav-label {
font-size: 11px;
font-weight: var(--font-weight-medium);
line-height: 1;
}
.bottom-nav-item.active .bottom-nav-label {
font-weight: var(--font-weight-semibold);
}
.bottom-nav-badge {
position: absolute;
top: 2px;
left: calc(50% + 6px);
min-width: 17px;
height: 17px;
padding: 0 4px;
background: var(--c-danger);
color: var(--on-dark);
border: 1.5px solid var(--bg2);
border-radius: var(--radius-pill);
font-family: var(--font-display);
font-size: 10px;
font-weight: var(--font-weight-bold);
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,394 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts" name="MoreSheet">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNavItems, navIconSvg, formatBadgeCount } from './navItems'
import InstallPrompt from '@/components/pwa/InstallPrompt.vue'
import type { ThemeMode } from '@/composables/useTheme'
const props = defineProps<{
open: boolean
isOnline?: boolean
statusUpdating?: boolean
themeMode?: ThemeMode
unreadCount?: number
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toggle-status'): void
(e: 'toggle-theme'): void
(e: 'notifications'): void
(e: 'logout'): void
}>()
const router = useRouter()
const { moreNavGroups } = useNavItems()
const themeLabel = computed(() =>
props.themeMode === 'dark' ? '深色' : props.themeMode === 'light' ? '浅色' : '跟随系统'
)
const themeIconName = computed(() =>
props.themeMode === 'dark' ? 'moon' : props.themeMode === 'light' ? 'sun' : 'monitor'
)
const badgeText = computed(() => formatBadgeCount(props.unreadCount))
const navigate = (to?: string) => {
if (!to) return
emit('close')
router.push(to)
}
</script>
<template>
<Teleport to="body">
<Transition name="more-sheet">
<div v-if="open" class="more-sheet-root">
<div class="more-scrim" @click="emit('close')"></div>
<div class="more-sheet" role="dialog" aria-label="更多">
<div class="drag-handle" aria-hidden="true"></div>
<!-- Availability -->
<button
type="button"
class="availability-card"
:class="{ offline: !isOnline }"
:disabled="statusUpdating"
@click="emit('toggle-status')"
>
<span class="availability-dot" :class="{ online: isOnline }"></span>
<span class="availability-text">
<span class="availability-title">{{ isOnline ? "你当前在线" : "你当前离线" }}</span>
<span class="availability-sub">{{ isOnline ? '接收新会话' : '暂停接收新会话' }}</span>
</span>
<span class="toggle-track" :class="{ on: isOnline }">
<span class="toggle-knob"></span>
</span>
</button>
<!-- Theme -->
<button type="button" class="sheet-row standalone" @click="emit('toggle-theme')">
<span class="row-icon theme-icon" aria-hidden="true" v-html="navIconSvg(themeIconName, 20)"></span>
<span class="row-label">主题</span>
<span class="row-value">{{ themeLabel }}</span>
</button>
<!-- Overflow nav links, grouped exactly as the desktop sidebar -->
<template v-for="(group, index) in moreNavGroups" :key="group.section || index">
<div v-if="group.section" class="sheet-section nav-section-heading">{{ group.section }}</div>
<div class="sheet-card">
<button
v-for="item in group.items"
:key="item.to"
type="button"
class="sheet-row"
@click="navigate(item.to)"
>
<span class="row-icon" v-html="navIconSvg(item.icon, 20)"></span>
<span class="row-label">{{ item.label }}</span>
<span class="row-chevron" aria-hidden="true" v-html="navIconSvg('chevronRight', 18)"></span>
</button>
</div>
</template>
<!-- Notifications -->
<button type="button" class="sheet-row standalone" @click="emit('notifications')">
<span class="row-icon" v-html="navIconSvg('bell', 20)"></span>
<span class="row-label">通知</span>
<span v-if="unreadCount" class="row-badge">{{ badgeText }}</span>
</button>
<!-- Install as app (hidden once installed) -->
<div class="install-slot">
<InstallPrompt />
</div>
<!-- Logout -->
<button type="button" class="logout-row" @click="emit('logout')">
<span class="row-icon" v-html="navIconSvg('logout', 19)"></span>
退出登录
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.more-sheet-root {
position: fixed;
inset: 0;
z-index: var(--z-sheet);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.more-scrim {
position: absolute;
inset: 0;
background: var(--scrim);
backdrop-filter: blur(2px);
}
.more-sheet {
position: relative;
background: var(--bg2);
border-top: 1px solid var(--o07);
border-top-left-radius: 24px;
border-top-right-radius: 24px;
padding: 12px 18px calc(var(--space-lg) + var(--safe-bottom));
box-shadow: var(--shadow-lg);
max-height: calc(100dvh - 60px);
overflow-y: auto;
}
.drag-handle {
width: 38px;
height: 5px;
border-radius: 3px;
background: var(--o10);
margin: 0 auto 18px;
}
.availability-card {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 16px;
background: var(--success-color-soft);
border: 1px solid color-mix(in srgb, var(--success-color) 35%, transparent);
margin-bottom: 14px;
cursor: pointer;
font-family: var(--font-sans);
color: var(--text);
text-align: left;
transition: opacity var(--transition-fast);
}
.availability-card.offline {
background: var(--o04);
border-color: var(--o10);
}
.availability-card:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.availability-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
}
.availability-dot.online {
background: var(--success-color);
box-shadow: 0 0 0 4px var(--success-color-soft);
}
.availability-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.availability-title {
font-weight: var(--font-weight-semibold);
font-size: 15px;
}
.availability-sub {
font-size: 12px;
color: var(--muted);
}
.toggle-track {
width: 48px;
height: 28px;
border-radius: 16px;
background: var(--toggle-track-off);
position: relative;
flex-shrink: 0;
transition: background-color var(--transition-fast);
}
.toggle-track.on {
background: var(--success-color);
}
.toggle-knob {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--toggle-knob);
transition: transform var(--transition-fast);
}
.toggle-track.on .toggle-knob {
transform: translateX(20px);
}
/* Typography comes from the shared .nav-section-heading (components.css) */
.sheet-section {
padding: 0 4px;
margin-bottom: 8px;
}
.sheet-card {
background: var(--o04);
border: 1px solid var(--o07);
border-radius: 16px;
overflow: hidden;
margin-bottom: 14px;
}
.sheet-card .sheet-row + .sheet-row {
border-top: 1px solid var(--o06);
}
.sheet-row {
width: 100%;
display: flex;
align-items: center;
gap: 13px;
padding: 14px 16px;
background: none;
border: none;
color: var(--text);
font-family: var(--font-sans);
font-size: 15px;
font-weight: var(--font-weight-medium);
cursor: pointer;
text-align: left;
transition: background-color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
}
.sheet-row:hover {
background: var(--o05);
}
.sheet-row.standalone {
background: var(--o04);
border: 1px solid var(--o07);
border-radius: 14px;
margin-bottom: 14px;
}
.row-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--text3);
flex-shrink: 0;
}
.theme-icon {
color: var(--c-purple);
}
.row-label {
flex: 1;
min-width: 0;
}
.row-value {
color: var(--muted);
font-size: 14px;
}
.row-chevron {
color: var(--muted);
display: flex;
}
.row-badge {
min-width: 22px;
height: 22px;
padding: 0 7px;
border-radius: 11px;
background: var(--c-coral);
color: var(--on-light);
font-size: 12px;
font-weight: var(--font-weight-bold);
display: flex;
align-items: center;
justify-content: center;
}
.install-slot {
margin-bottom: 14px;
}
.install-slot:empty {
display: none;
}
.logout-row {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 13px;
background: none;
border: none;
color: var(--c-coral);
font-family: var(--font-sans);
font-weight: var(--font-weight-semibold);
font-size: 15px;
cursor: pointer;
}
.logout-row .row-icon {
color: inherit;
}
/* Slide-up transition */
.more-sheet-enter-active,
.more-sheet-leave-active {
transition: opacity var(--transition-normal);
}
.more-sheet-enter-active .more-sheet,
.more-sheet-leave-active .more-sheet {
transition: transform var(--transition-normal);
}
.more-sheet-enter-from,
.more-sheet-leave-to {
opacity: 0;
}
.more-sheet-enter-from .more-sheet,
.more-sheet-leave-to .more-sheet {
transform: translateY(100%);
}
</style>
@@ -0,0 +1,64 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
defineProps<{
isCollapsed: boolean
}>()
defineEmits<{
(e: 'toggle'): void
}>()
</script>
<template>
<button class="toggle-btn" @click="$emit('toggle')">
<span class="toggle-icon">{{ isCollapsed ? '▶' : '◀' }}</span>
</button>
</template>
<style scoped>
.toggle-btn {
width: 26px;
height: 26px;
flex-shrink: 0;
background: var(--o05);
border: 1px solid var(--o10);
border-radius: 7px;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--transition-fast), color var(--transition-fast),
border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.toggle-btn:hover {
background: var(--o10);
color: var(--text2);
}
.toggle-btn:focus-visible {
outline: none;
box-shadow: var(--ring-focus);
}
.toggle-icon {
font-size: 12px;
line-height: 1;
}
</style>
@@ -0,0 +1,57 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
// Shared inline stroke icon set (stroke="currentColor" so glyphs inherit the
// surrounding color — muted by default, lime when active). One source of truth
// for the sidebar, bottom nav, More sheet, and prompts.
export const NAV_ICONS: Record<string, string> = {
agents: '<rect x="5" y="8" width="14" height="11" rx="3"/><line x1="12" y1="4" x2="12" y2="8"/><circle cx="9" cy="13" r="1" fill="currentColor"/><circle cx="15" cy="13" r="1" fill="currentColor"/>',
humans: '<circle cx="9" cy="8" r="3"/><path d="M3.5 19a5.5 5 0 0 1 11 0"/><circle cx="16.5" cy="9" r="2.3"/><path d="M15 19a4.5 4 0 0 1 5.5-3.6"/>',
inbox: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M3 13h5l1.5 2.5h4L19 13h2"/>',
people: '<circle cx="9" cy="8" r="2.6"/><path d="M4 18a5 4.5 0 0 1 10 0"/><path d="M15.5 6.2a2.6 2.6 0 0 1 0 4.6"/><path d="M16 13.6A5 4.5 0 0 1 20 18"/>',
analytics: '<line x1="5" y1="17" x2="5" y2="13"/><line x1="10" y1="17" x2="10" y2="9"/><line x1="15" y1="17" x2="15" y2="6"/><line x1="20" y1="17" x2="20" y2="11"/>',
knowledge: '<path d="M4 5.5A2 2 0 0 1 6 4h5v16H6a2 2 0 0 0-2 1.5z"/><path d="M20 5.5A2 2 0 0 0 18 4h-5v16h5a2 2 0 0 1 2 1.5z"/>',
faq: '<circle cx="12" cy="12" r="9"/><path d="M9.3 9.3a2.7 2.7 0 0 1 5.2 1c0 1.8-2.5 2-2.5 3.3"/><circle cx="12" cy="17" r=".6" fill="currentColor"/>',
tickets: '<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2 2 2 0 0 0 0-4z"/><line x1="14" y1="8" x2="14" y2="16" stroke-dasharray="2 2.4"/>',
ticketing: '<circle cx="12" cy="12" r="3"/><path d="M12 4v2M12 18v2M4 12h2M18 12h2M6 6l1.5 1.5M16.5 16.5 18 18M18 6l-1.5 1.5M7.5 16.5 6 18"/>',
org: '<rect x="4" y="4" width="14" height="16" rx="2"/><line x1="20" y1="20" x2="20" y2="11"/><line x1="18" y1="11" x2="22" y2="11"/><circle cx="8" cy="9" r=".6" fill="currentColor"/><circle cx="12" cy="9" r=".6" fill="currentColor"/><circle cx="8" cy="13" r=".6" fill="currentColor"/><circle cx="12" cy="13" r=".6" fill="currentColor"/>',
subscription: '<rect x="3" y="6" width="18" height="12" rx="2.5"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="7" y1="14" x2="11" y2="14"/>',
integrations: '<line x1="12" y1="12" x2="6" y2="6"/><line x1="12" y1="12" x2="18" y2="6"/><line x1="12" y1="12" x2="12" y2="19"/><circle cx="12" cy="12" r="2.2" fill="currentColor"/><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="19" r="2"/>',
widgets: '<rect x="4" y="4" width="7" height="7" rx="2"/><rect x="13" y="4" width="7" height="7" rx="2"/><rect x="4" y="13" width="7" height="7" rx="2"/><circle cx="16.5" cy="16.5" r="3.5"/>',
aiconfig: '<line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="9" cy="8" r="2.4"/><circle cx="15" cy="16" r="2.4"/>',
usersettings: '<circle cx="12" cy="8" r="3.4"/><path d="M5.5 19a6.5 5.5 0 0 1 13 0"/>',
more: '<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/>',
bell: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9z"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>',
logout: '<path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M10 12h11m0 0-3-3m3 3-3 3"/>',
chevronRight: '<path d="M9 6l6 6-6 6"/>',
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
sun: '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
}
// Icons are static — cache built strings so hot paths (bottom nav re-renders
// on every route change) don't rebuild them.
const svgCache = new Map<string, string>()
export const navIconSvg = (name?: string, size = 19) => {
const key = `${name}:${size}`
let svg = svgCache.get(key)
if (!svg) {
svg = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
svgCache.set(key, svg)
}
return svg
}
+197
View File
@@ -0,0 +1,197 @@
/*
Copyright 2024-2026 ChatterMate
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 { computed } from 'vue'
import { permissionChecks } from '@/utils/permissions'
// Visibility comes from the same map the router enforces, so a link can never
// appear for a page the guard will refuse (People used to do exactly that).
import { canAccessPath } from '@/router/routePermissions'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
// Re-exported so nav consumers keep a single import site
export { NAV_ICONS, navIconSvg } from './navIcons'
export interface NavItem {
to?: string
icon?: string
label?: string
section?: string
show?: boolean
}
export interface NavGroup {
section: string
items: NavItem[]
}
// Shared unread-badge cap (bottom nav, More sheet, header bell)
export const formatBadgeCount = (count?: number) =>
count && count > 99 ? '99+' : String(count || '')
// Bottom-nav primary slots, in display order (remaining links go to the More sheet)
export const PRIMARY_NAV_PATHS = ['/conversations', '/people', '/ai-agents', '/analytics']
export function useNavItems() {
const { hasEnterpriseModule } = useEnterpriseFeatures()
// Section membership is explicit rather than inferred from array position:
// a header can be permission-hidden while one of its items is not (User
// Settings is always visible), which silently orphaned items into the
// preceding section.
const navGroups = computed<NavGroup[]>(() =>
[
{
section: '主菜单',
items: [
{
to: '/ai-agents',
icon: 'agents',
label: 'AI 客服',
show: canAccessPath('/ai-agents'),
},
{
to: '/human-agents',
icon: 'humans',
label: '人工客服',
show: canAccessPath('/human-agents'),
},
{
to: '/conversations',
icon: 'inbox',
label: '会话',
show: canAccessPath('/conversations'),
},
{
to: '/tickets',
icon: 'tickets',
label: '工单',
show: canAccessPath('/tickets'),
},
{
to: '/people',
icon: 'people',
label: '联系人',
show: canAccessPath('/people'),
},
{
to: '/knowledge',
icon: 'knowledge',
label: '知识库',
show: canAccessPath('/knowledge'),
},
{
to: '/faq',
icon: 'faq',
label: '帮助中心',
show: canAccessPath('/faq'),
},
{
to: '/analytics',
icon: 'analytics',
label: '数据分析',
show: canAccessPath('/analytics'),
},
],
},
{
section: '设置',
items: [
{
to: '/settings/organization',
icon: 'org',
label: '组织',
show: canAccessPath('/settings/organization'),
},
{
to: '/settings/subscription',
icon: 'subscription',
label: '订阅',
show: hasEnterpriseModule && permissionChecks.canViewSubscription(),
},
{
to: '/settings/ticketing',
icon: 'ticketing',
label: '工单设置',
show: canAccessPath('/settings/ticketing'),
},
{
to: '/settings/integrations',
icon: 'integrations',
label: '集成',
show: canAccessPath('/settings/integrations'),
},
{
to: '/settings/app-integrations',
icon: 'integrations',
label: '应用集成',
show: hasEnterpriseModule && canAccessPath('/settings/app-integrations'),
},
{
to: '/settings/widget-apps',
icon: 'widgets',
label: '网站组件',
show: canAccessPath('/settings/widget-apps'),
},
{
to: '/settings/ai-config',
icon: 'aiconfig',
label: 'AI 配置',
show: canAccessPath('/settings/ai-config'),
},
{
to: '/settings/user',
icon: 'usersettings',
label: '个人设置',
show: true,
},
],
},
]
.map((group) => ({ ...group, items: group.items.filter((item) => item.show !== false) }))
// A section is visible when it has something to show — no separate
// permission flag to drift from its items
.filter((group) => group.items.length > 0),
)
// Flat list (heading followed by its links) for the desktop sidebar
const navItems = computed<NavItem[]>(() =>
navGroups.value.flatMap((group) => [{ section: group.section }, ...group.items]),
)
// Bottom-nav slots in design order
const primaryNavItems = computed<NavItem[]>(() =>
PRIMARY_NAV_PATHS.map((path) => navItems.value.find((item) => item.to === path)).filter(
(item): item is NavItem => !!item,
),
)
// Everything the bottom nav doesn't carry, still grouped like the sidebar
const moreNavGroups = computed<NavGroup[]>(() =>
navGroups.value
.map((group) => ({
...group,
items: group.items.filter((item) => item.to && !PRIMARY_NAV_PATHS.includes(item.to)),
}))
.filter((group) => group.items.length > 0),
)
// Derived from the groups so there is one definition of "overflow link"
const moreNavItems = computed<NavItem[]>(() =>
moreNavGroups.value.flatMap((group) => group.items),
)
return { navGroups, navItems, primaryNavItems, moreNavItems, moreNavGroups }
}
@@ -0,0 +1,869 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useOrganizationSettings } from '@/composables/useOrganizationSettings'
// @ts-ignore
import { timezoneOptions } from '@/utils/timezones'
const {
formData,
loading,
message,
error,
stats,
hasChanges,
days,
timeOptions,
loadOrganizationData,
updateOrganization
} = useOrganizationSettings()
const timezones = ref(timezoneOptions())
// Danger zone — no destructive API yet; route the user to support.
const onTransferOwnership = () => {
window.alert('如需转移所有权,请联系 support@chattermate.chat。')
}
const onDeleteOrganization = () => {
if (window.confirm('永久删除该组织及其全部数据?此操作无法撤销。')) {
window.alert('删除组织需由支持团队完成 — 请联系 support@chattermate.chat。')
}
}
// Business-hours presets — pure client-side mutation of formData
const applyPreset = (preset: 'weekday' | 'everyday' | 'always' | 'clear') => {
for (const day of days) {
const bh = formData.value.business_hours[day.key]
const isWeekend = day.key === 'saturday' || day.key === 'sunday'
switch (preset) {
case 'weekday':
bh.enabled = !isWeekend
bh.start = '09:00'
bh.end = '17:00'
break
case 'everyday':
bh.enabled = true
bh.start = '09:00'
bh.end = '18:00'
break
case 'always':
bh.enabled = true
bh.start = '00:00'
bh.end = '23:45'
break
case 'clear':
bh.enabled = false
break
}
}
}
const discardChanges = () => {
// Reload from server-backed original state
loadOrganizationData()
}
onMounted(async () => {
await loadOrganizationData()
})
</script>
<template>
<div class="org-settings">
<div v-if="loading && !formData.name" class="loading-container">
<div class="loader"></div>
</div>
<div v-else class="org-page">
<!-- Page header -->
<header class="org-header">
<h1 class="org-title">组织</h1>
<p class="org-subtitle">你的工作区资料、营业时间与团队概览。</p>
</header>
<!-- Stats strip -->
<div v-if="stats" class="stats-strip">
<div class="stat-card">
<span class="stat-label">成员</span>
<span class="stat-value">{{ stats.members_total ?? stats.total_users ?? 0 }}</span>
<span class="stat-sub members">{{ stats.members_admins ?? 0 }} 名管理员 · {{ stats.members_agents ?? 0 }} 名客服</span>
</div>
<div class="stat-card">
<span class="stat-label">当前在线</span>
<span class="stat-value">{{ stats.active_now ?? 0 }}</span>
<span class="stat-sub active">今日活跃</span>
</div>
<div class="stat-card">
<span class="stat-label">AI 智能体</span>
<span class="stat-value">{{ stats.agents_total ?? 0 }}</span>
<span class="stat-sub agents">{{ stats.agents_live ?? 0 }} 个已上线 · {{ stats.agents_draft ?? 0 }} 个草稿</span>
</div>
<div class="stat-card">
<span class="stat-label">会话 · 近 30 天</span>
<span class="stat-value">{{ (stats.conversations_30d ?? 0).toLocaleString() }}</span>
<span class="stat-sub conversations">
{{ (stats.conversations_change_pct ?? 0) >= 0 ? '▲' : '▼' }}
{{ Math.abs(stats.conversations_change_pct ?? 0) }}% 较上期
</span>
</div>
</div>
<form @submit.prevent="updateOrganization">
<!-- PROFILE -->
<section class="card">
<div class="card-head">
<h3 class="card-title">资料</h3>
<p class="card-desc">你的工作区在团队与客户面前展示的样子。</p>
</div>
<div class="field-grid">
<div class="field">
<label>组织名称</label>
<input
type="text"
v-model="formData.name"
placeholder="组织名称"
>
</div>
<div class="field">
<label>主域名</label>
<input
type="text"
class="mono"
v-model="formData.domain"
placeholder="example.com"
>
</div>
</div>
<div class="field">
<label>时区</label>
<select class="select" v-model="formData.timezone">
<option
v-for="tz in timezones"
:key="tz.value"
:value="tz.value"
>
{{ tz.label }}
</option>
</select>
<p class="field-hint">用于工作区内的营业时间、报表与时间戳。</p>
</div>
</section>
<!-- BUSINESS HOURS -->
<section class="card">
<div class="card-head-row">
<div>
<h3 class="card-title">营业时间</h3>
<p class="card-desc">人工团队的服务时段。非营业时间由 AI 处理所有会话。</p>
</div>
<div class="presets">
<button type="button" class="preset" @click="applyPreset('weekday')">工作日 9–17</button>
<button type="button" class="preset" @click="applyPreset('everyday')">每天 9–18</button>
<button type="button" class="preset" @click="applyPreset('always')">24/7</button>
<button type="button" class="preset preset-ghost" @click="applyPreset('clear')">全部清除</button>
</div>
</div>
<div class="hours-list">
<div v-for="day in days" :key="day.key" class="day-row">
<button
type="button"
class="toggle"
:class="{ on: formData.business_hours[day.key].enabled }"
role="switch"
:aria-checked="formData.business_hours[day.key].enabled"
@click="formData.business_hours[day.key].enabled = !formData.business_hours[day.key].enabled"
>
<span class="knob"></span>
</button>
<span
class="day-label"
:class="{ off: !formData.business_hours[day.key].enabled }"
>{{ day.label }}</span>
<select
class="time-select"
v-model="formData.business_hours[day.key].start"
:disabled="!formData.business_hours[day.key].enabled"
>
<option v-for="time in timeOptions" :key="time" :value="time">{{ time }}</option>
</select>
<span class="time-sep">至</span>
<select
class="time-select"
v-model="formData.business_hours[day.key].end"
:disabled="!formData.business_hours[day.key].enabled"
>
<option v-for="time in timeOptions" :key="time" :value="time">{{ time }}</option>
</select>
<span
class="day-state"
:class="formData.business_hours[day.key].enabled ? 'open' : 'closed'"
>{{ formData.business_hours[day.key].enabled ? '开放' : '关闭' }}</span>
</div>
</div>
</section>
<!-- DANGER ZONE -->
<section class="card danger-card">
<h3 class="danger-title">危险操作</h3>
<div class="danger-row">
<div>
<div class="danger-row-title">转移所有权</div>
<div class="danger-row-desc">将此工作区交给其他管理员。</div>
</div>
<button type="button" class="danger-btn" @click="onTransferOwnership">转移</button>
</div>
<div class="danger-row last">
<div>
<div class="danger-row-title">删除组织</div>
<div class="danger-row-desc">永久删除此工作区及其全部数据。</div>
</div>
<button type="button" class="danger-btn delete" @click="onDeleteOrganization">删除</button>
</div>
</section>
<p v-if="message" class="success-message">{{ message }}</p>
<p v-if="error" class="error-message">{{ error }}</p>
</form>
</div>
<!-- Sticky save bar -->
<Transition name="save-bar">
<div v-if="hasChanges" class="save-bar">
<span class="save-bar-text">有未保存的更改</span>
<div class="save-bar-actions">
<button type="button" class="btn-discard" @click="discardChanges" :disabled="loading">放弃</button>
<button type="button" class="btn-save" @click="updateOrganization" :disabled="loading">
{{ loading ? '保存中…' : '保存更改' }}
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.org-settings {
width: 100%;
}
.org-page {
max-width: 900px;
margin: 0 auto;
padding: 4px 0 24px;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 240px;
}
.loader {
width: 48px;
height: 48px;
border: 5px solid var(--primary-soft);
border-bottom-color: var(--accent-ink);
border-radius: 50%;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% { transform: rotate(0deg) }
100% { transform: rotate(360deg) }
}
/* Page header */
.org-header {
margin-bottom: 26px;
}
.org-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 30px;
letter-spacing: -0.02em;
color: var(--text);
margin: 0 0 6px;
}
.org-subtitle {
font-size: 15px;
color: var(--muted);
margin: 0;
}
/* Stats strip */
.stats-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 16px;
padding: 18px 20px;
display: flex;
flex-direction: column;
}
.stat-label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.06em;
color: var(--muted2);
}
.stat-value {
font-family: var(--font-display);
font-size: 28px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.02em;
margin: 8px 0 4px;
}
.stat-sub {
font-size: 12.5px;
}
.stat-sub.members { color: var(--c-online); }
.stat-sub.active { color: var(--accent-ink); }
.stat-sub.agents { color: var(--c-pro); }
.stat-sub.conversations { color: var(--c-coral); }
@media (max-width: 980px) {
.stats-strip {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.stats-strip {
grid-template-columns: 1fr;
}
}
/* Section cards */
.card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 18px;
padding: 26px;
margin: 0 0 18px;
max-width: none;
width: 100%;
box-sizing: border-box;
}
/* Danger zone */
.danger-card {
border-color: var(--coral-border);
padding: 24px;
}
.danger-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
margin: 0 0 16px;
color: var(--c-coral);
}
.danger-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--o06);
}
.danger-row.last {
padding: 14px 0 0;
border-bottom: none;
}
.danger-row-title {
font-size: 14px;
font-weight: 600;
color: var(--text2);
}
.danger-row-desc {
font-size: 12.5px;
color: var(--muted);
margin-top: 2px;
}
.danger-btn {
flex-shrink: 0;
padding: 9px 16px;
background: transparent;
border: 1px solid var(--o14);
border-radius: 10px;
color: var(--text);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.danger-btn:hover {
background: var(--o06);
}
.danger-btn.delete {
background: var(--coral-bg);
border-color: var(--coral-border);
color: var(--c-coral);
font-weight: 600;
}
.danger-btn.delete:hover {
background: color-mix(in srgb, var(--c-coral) 18%, transparent);
}
.card-head {
margin-bottom: 22px;
}
.card-head-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
color: var(--text);
margin: 0 0 4px;
}
.card-desc {
font-size: 13.5px;
color: var(--muted);
margin: 0;
}
/* Logo row */
.logo-row {
display: flex;
align-items: center;
gap: 18px;
padding-bottom: 22px;
border-bottom: 1px solid var(--o07);
margin-bottom: 22px;
}
.org-logo {
width: 62px;
height: 62px;
flex-shrink: 0;
border-radius: 16px;
background: var(--grad-lime-teal);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 26px;
color: var(--on-accent-solid);
overflow: hidden;
}
.org-logo.has-image {
background: var(--surface);
border: 1px solid var(--o10);
}
.org-logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.logo-meta {
flex: 1;
min-width: 0;
}
.logo-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.hidden-input {
display: none;
}
.logo-btn {
padding: 9px 18px;
background: var(--o05);
border: 1px solid var(--o14);
border-radius: var(--radius-chip);
color: var(--text);
font-family: var(--font-sans);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.logo-btn:hover:not(:disabled) {
background: var(--o10);
}
.logo-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.logo-meta-title {
font-size: 14px;
font-weight: 600;
color: var(--text2);
margin-bottom: 3px;
}
.logo-meta-sub {
font-size: 12.5px;
color: var(--muted2);
}
/* Fields */
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 18px;
}
.field label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text3);
margin-bottom: 8px;
}
.field input,
.select {
width: 100%;
padding: 13px 15px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: var(--radius-input);
color: var(--text);
font-size: 14.5px;
font-family: var(--font-sans);
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.field input.mono {
font-family: var(--font-mono);
}
.field input::placeholder {
color: var(--faint);
}
.field input:focus,
.select:focus {
border-color: var(--accent-ink);
box-shadow: var(--ring-focus);
}
.select {
cursor: pointer;
appearance: none;
padding-right: 38px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239CA3B0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 13px center;
background-size: 16px;
}
.field-hint {
font-size: 12.5px;
color: var(--muted2);
margin: 8px 0 0;
}
/* Business hours — presets */
.presets {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.preset {
padding: 8px 13px;
background: var(--o05);
border: 1px solid var(--o12);
border-radius: 9px;
color: var(--text3);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
font-family: var(--font-sans);
transition: background var(--transition-fast), color var(--transition-fast);
}
.preset:hover {
background: var(--o10);
}
.preset-ghost {
background: transparent;
border-color: var(--o10);
color: var(--muted);
}
.preset-ghost:hover {
background: transparent;
color: var(--text3);
}
/* Day rows */
.hours-list {
display: flex;
flex-direction: column;
}
.day-row {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 0;
border-bottom: 1px solid var(--o05);
}
.day-row:last-child {
border-bottom: none;
}
.day-label {
font-size: 14px;
font-weight: 500;
color: var(--text);
min-width: 92px;
}
.day-label.off {
color: var(--muted2);
}
/* Toggle */
.toggle {
position: relative;
flex-shrink: 0;
width: 40px;
height: 22px;
padding: 0;
border: none;
border-radius: 999px;
background: var(--toggle-track-off);
cursor: pointer;
transition: background var(--transition-fast);
}
.toggle.on {
background: var(--toggle-on-accent);
}
.knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--toggle-knob);
transition: transform var(--transition-fast);
}
.toggle.on .knob {
transform: translateX(18px);
}
/* Time selects */
.time-select {
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: var(--radius-md);
color: var(--text);
font-size: 13.5px;
font-family: var(--font-mono);
outline: none;
cursor: pointer;
transition: border-color var(--transition-fast);
}
.time-select:focus {
border-color: var(--accent-ink);
}
.time-select:disabled {
background: var(--o03);
color: var(--muted);
cursor: not-allowed;
}
.time-sep {
color: var(--muted2);
font-size: 13px;
}
.day-state {
margin-left: auto;
font-size: 12.5px;
font-weight: 500;
}
.day-state.open {
color: var(--c-teal);
}
.day-state.closed {
color: var(--muted2);
}
/* Messages */
.success-message {
color: var(--c-teal);
font-size: 13.5px;
margin: 4px 0 0;
}
.error-message {
color: var(--c-coral);
font-size: 13.5px;
margin: 4px 0 0;
}
/* Sticky save bar */
.save-bar {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
z-index: 50;
display: flex;
align-items: center;
gap: 18px;
padding: 12px 14px 12px 22px;
background: color-mix(in srgb, var(--surface) 96%, transparent);
backdrop-filter: blur(12px);
border: 1px solid var(--o12);
border-radius: 14px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
}
.save-bar-text {
font-size: 13.5px;
color: var(--text3);
}
.save-bar-actions {
display: flex;
gap: 10px;
}
.btn-discard {
padding: 10px 16px;
background: transparent;
border: 1px solid var(--o14);
border-radius: 10px;
color: var(--text3);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
font-family: var(--font-sans);
transition: background var(--transition-fast);
}
.btn-discard:hover:not(:disabled) {
background: var(--o06);
}
.btn-save {
padding: 10px 20px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 10px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
font-family: var(--font-sans);
transition: filter var(--transition-fast);
}
.btn-save:hover:not(:disabled) {
filter: brightness(1.05);
}
.btn-save:disabled,
.btn-discard:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.save-bar-enter-active,
.save-bar-leave-active {
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.save-bar-enter-from,
.save-bar-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(12px);
}
/* Responsive */
@media (max-width: 640px) {
.field-grid {
grid-template-columns: 1fr;
}
.day-row {
flex-wrap: wrap;
}
.day-state {
margin-left: 0;
}
}
</style>
@@ -0,0 +1,409 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { peopleService, type PersonCrmStatus } from '@/services/people'
import channelsService, { type ChannelAccount } from '@/services/channels'
import NewWhatsAppConversation from '@/components/conversations/NewWhatsAppConversation.vue'
import type { PersonDetail } from '@/types/people'
import { permissionChecks } from '@/utils/permissions'
const PROVIDER_LABELS: Record<string, string> = { hubspot: 'HubSpot', pipedrive: 'Pipedrive' }
const providerLabel = (p: string) => PROVIDER_LABELS[p] || p
const props = defineProps<{ customerId: string }>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'updated', stage?: string): void }>()
const person = ref<PersonDetail | null>(null)
const loading = ref(true)
const marking = ref(false)
// Inline contact edit — the identification tool: adding a phone/name is what
// turns an anonymous session into a person (and unlocks the actions below).
const editing = ref(false)
const saving = ref(false)
const editName = ref('')
const editPhone = ref('')
function startEdit() {
editName.value = person.value?.name || ''
editPhone.value = person.value?.phone || ''
editing.value = true
}
async function saveEdit() {
saving.value = true
try {
person.value = await peopleService.updatePerson(props.customerId, {
full_name: editName.value.trim() || undefined,
// "" deliberately passes through: it clears a wrong number.
phone: editPhone.value.trim(),
})
editing.value = false
emit('updated')
} catch (error: any) {
toast.error('保存失败', {
description: error?.response?.data?.detail || '请重试',
})
} finally {
saving.value = false
}
}
const attrEntries = computed(() => Object.entries(person.value?.captured_attributes || {}))
// CRM sync state for this person
const crm = ref<PersonCrmStatus | null>(null)
const syncing = ref(false)
const crmConnected = computed(() => (crm.value?.connected_providers.length ?? 0) > 0)
const crmSynced = computed(() => crm.value?.synced || [])
const syncedSummary = computed(() =>
crmSynced.value.map(s => providerLabel(s.provider)).join(', '))
const connectedSummary = computed(() =>
(crm.value?.connected_providers || []).map(providerLabel).join(', '))
// The CRM dedupes on email, so a person needs one before they can sync.
// Editing a person, marking them a customer and pushing to CRM all take the
// same grant the API requires (PEOPLE_WRITE_PERMISSIONS). These buttons used to
// render for everyone who could read the directory and 403 on click.
const canEditPeople = permissionChecks.canManagePeople()
const canSync = computed(() => !!person.value?.email && canEditPeople)
async function loadCrm() {
try { crm.value = await peopleService.getCrmStatus(props.customerId) }
catch { /* non-fatal: the widget just shows nothing */ }
}
async function syncNow() {
syncing.value = true
try {
crm.value = await peopleService.syncToCrm(props.customerId)
toast.success('已同步至 CRM')
} catch (error: any) {
toast.error('同步失败', {
description: error?.response?.data?.detail || '请重试',
})
} finally {
syncing.value = false
}
}
function goToIntegrations() {
emit('close')
router.push('/settings/integrations')
}
async function load() {
loading.value = true
try { person.value = await peopleService.getPerson(props.customerId) }
catch { toast.error('加载联系人失败') }
finally { loading.value = false }
}
async function markCustomer() {
marking.value = true
try {
person.value = await peopleService.markAsCustomer(props.customerId)
toast.success('已标记为客户')
emit('updated', 'customer')
} catch (error: any) {
toast.error('标记为客户失败', {
description: error?.response?.data?.detail || undefined,
})
} finally {
marking.value = false
}
}
// "Message on WhatsApp" — the drawer is the person-centric entry point to the
// same modal the Conversations button opens. Disabled-with-reason, matching
// the Sync-now pattern above it.
const router = useRouter()
const whatsappAccounts = ref<ChannelAccount[]>([])
const showNewConversation = ref(false)
async function loadWhatsAppAccounts() {
whatsappAccounts.value = await channelsService.listActiveWhatsAppAccounts()
}
const whatsappDisabledReason = computed(() => {
if (!whatsappAccounts.value.length) return '请先在 设置 → 集成 中连接 WhatsApp'
return ''
})
function onConversationStarted(sessionId: string) {
showNewConversation.value = false
emit('close')
router.push({ path: '/conversations', query: { session: sessionId } })
}
function stageLabel(s?: string) {
const labels: Record<string, string> = { visitor: '访客', lead: '线索', customer: '客户' }
return s ? (labels[s] || s) : ''
}
function fmt(d?: string | null) {
if (!d) return ''
try { return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) } catch { return '' }
}
onMounted(() => { load(); loadWhatsAppAccounts(); loadCrm() })
</script>
<template>
<div class="pdd-overlay" @click.self="emit('close')">
<aside class="pdd">
<div class="pdd-head">
<div class="pdd-head-main" v-if="person">
<div class="pdd-name">{{ person.name || (person.is_anonymous ? '匿名访客' : (person.email || '—')) }}</div>
<div class="pdd-email">
{{ person.is_anonymous ? '匿名' : (person.email || '') }}
<span v-if="person.phone" class="pdd-phone">{{ person.phone }}</span>
</div>
</div>
<button class="pdd-close" @click="emit('close')">✕</button>
</div>
<div v-if="loading" class="pdd-loading">加载中…</div>
<div v-else-if="person" class="pdd-body">
<div class="pdd-stagerow">
<span class="pdd-badge" :class="person.lead_stage">{{ stageLabel(person.lead_stage) }}</span>
<span v-if="person.qualified" class="pdd-star" title="优质线索">★ 优质线索</span>
</div>
<!-- CRM sync -->
<div class="pdd-sync">
<!-- No CRM connected for the org -->
<template v-if="!crmConnected">
<span class="pdd-sync-text">尚未连接 CRM。</span>
<button class="pdd-sync-btn" @click="goToIntegrations">连接</button>
</template>
<!-- Already synced -->
<template v-else-if="crmSynced.length">
<span class="pdd-sync-text">
已同步至 {{ syncedSummary }}
<span v-if="crmSynced[0].synced_at" class="pdd-sync-when"> · {{ fmt(crmSynced[0].synced_at) }}</span>
<a v-if="crmSynced[0].record_url" :href="crmSynced[0].record_url" target="_blank" rel="noopener" class="pdd-sync-link">在 CRM 中查看 ↗</a>
</span>
<button class="pdd-sync-btn" :disabled="syncing || !canSync" @click="syncNow">
{{ syncing ? '同步中…' : '重新同步' }}
</button>
</template>
<!-- Connected but not yet synced -->
<template v-else>
<span class="pdd-sync-text">尚未同步至 {{ connectedSummary }}。</span>
<button
class="pdd-sync-btn primary"
:disabled="syncing || !canSync"
:title="canSync ? '' : '请先添加邮箱——CRM 按邮箱去重同步'"
@click="syncNow"
>
{{ syncing ? '同步中…' : '立即同步' }}
</button>
</template>
</div>
<button
v-if="canEditPeople && person.lead_stage !== 'customer'"
class="pdd-mark"
:disabled="marking || !person.identified"
:title="person.identified ? '' : '请先添加邮箱或电话——此人为匿名访客'"
@click="markCustomer"
>
{{ marking ? '标记中…' : '标记为客户' }}
</button>
<p v-if="!person.identified" class="pdd-identify-hint">
匿名访客——在下方添加姓名或电话即可识别。
</p>
<button
class="pdd-whatsapp"
:disabled="!!whatsappDisabledReason"
:title="whatsappDisabledReason"
@click="showNewConversation = true"
>
<font-awesome-icon :icon="['fab', 'whatsapp']" />
通过 WhatsApp 发送消息
</button>
<!-- Contact edit: the one place a wrong phone can be corrected -->
<div class="pdd-section-title">
联系方式
<button
v-if="canEditPeople && !editing"
type="button"
class="pdd-edit-link"
@click="startEdit"
>编辑</button>
</div>
<div v-if="editing" class="pdd-edit">
<label class="pdd-edit-field">
<span>姓名</span>
<input v-model="editName" placeholder="张三" autocomplete="off" />
</label>
<label class="pdd-edit-field">
<span>电话</span>
<input v-model="editPhone" placeholder="+91 12345 67890" autocomplete="off" />
</label>
<div class="pdd-edit-actions">
<button type="button" class="pdd-edit-btn" @click="editing = false">取消</button>
<button type="button" class="pdd-edit-btn primary" :disabled="saving" @click="saveEdit">
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<div v-else class="pdd-attrs">
<div class="pdd-attr"><span class="pdd-attr-k">电话</span><span class="pdd-attr-v">{{ person.phone || '—' }}</span></div>
</div>
<!-- AI qualification summary -->
<template v-if="person.summary">
<div class="pdd-section-title">AI 摘要</div>
<div class="pdd-summary">{{ person.summary }}</div>
</template>
<!-- Lifecycle -->
<div class="pdd-section-title">生命周期</div>
<div class="pdd-timeline">
<div v-for="(t, i) in person.timeline" :key="i" class="pdd-tl">
<span class="pdd-tl-dot" :class="t.stage"></span>
<span class="pdd-tl-label">{{ stageLabel(t.stage) }}</span>
<span class="pdd-tl-time">{{ fmt(t.at) }}</span>
</div>
</div>
<!-- Captured attributes -->
<div class="pdd-section-title">采集属性</div>
<div v-if="attrEntries.length" class="pdd-attrs">
<div v-for="[k, v] in attrEntries" :key="k" class="pdd-attr">
<span class="pdd-attr-k">{{ k }}</span>
<span class="pdd-attr-v">{{ v }}</span>
</div>
</div>
<div v-else class="pdd-none">暂未采集到属性。</div>
<!-- Conversations -->
<div class="pdd-section-title">会话 <span class="pdd-count">{{ person.conversations.length }}</span></div>
<div v-if="person.conversations.length" class="pdd-convos">
<div v-for="c in person.conversations" :key="c.session_id" class="pdd-convo">
<div class="pdd-convo-top">
<span class="pdd-agent">{{ c.agent_name || '客服' }}</span>
<span class="pdd-status">{{ c.status }}</span>
</div>
<div class="pdd-snippet">{{ c.last_message || '—' }}</div>
<div class="pdd-convo-date">{{ fmt(c.created_at) }}</div>
</div>
</div>
<div v-else class="pdd-none">暂无会话。</div>
</div>
</aside>
<NewWhatsAppConversation
v-if="showNewConversation && person"
:accounts="whatsappAccounts"
:person="{ id: person.id, name: person.name, phone: person.phone }"
@close="showNewConversation = false"
@started="onConversationStarted"
/>
</div>
</template>
<style scoped>
.pdd-overlay { position: fixed; inset: 0; background: var(--scrim); z-index: var(--z-drawer); display: flex; justify-content: flex-end; }
.pdd { width: 440px; max-width: 94vw; height: 100%; background: var(--surface); border-left: 1px solid var(--border-color); display: flex; flex-direction: column; box-shadow: -12px 0 40px rgba(0,0,0,.2); }
.pdd-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 22px; border-bottom: 1px solid var(--border-color); }
.pdd-name { font-size: 18px; font-weight: 700; }
.pdd-email { font-size: 12px; color: var(--muted); margin-top: 3px; }
.pdd-close { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; cursor: pointer; flex-shrink: 0; }
.pdd-loading { padding: 40px; text-align: center; color: var(--muted); }
.pdd-body { flex: 1; overflow-y: auto; padding: 20px 22px; }
.pdd-stagerow { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
.pdd-badge { padding: 4px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 600; }
.pdd-badge.visitor { background: rgba(0,0,0,.06); color: var(--muted); }
.pdd-badge.lead { background: var(--purple-bg); color: var(--c-purple); }
.pdd-badge.customer { background: var(--accent-bg-12); color: var(--primary-color); }
.pdd-star { font-size: 12.5px; color: #f59e0b; }
.pdd-sync { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; background: var(--o05); border: 1px solid var(--border-color); border-radius: 12px; margin-bottom: 12px; }
.pdd-sync-text { font-size: 13px; color: var(--muted); }
.pdd-sync-when { color: var(--muted); }
.pdd-sync-link { display: block; margin-top: 4px; color: var(--c-info); font-size: 12px; text-decoration: none; }
.pdd-sync-link:hover { text-decoration: underline; }
.pdd-sync-btn { flex-shrink: 0; padding: 7px 14px; border-radius: 9px; border: 1px solid var(--border-color); background: transparent; color: var(--text); font-size: 13px; cursor: pointer; }
.pdd-sync-btn:hover:not(:disabled) { background: var(--o10); }
.pdd-sync-btn.primary { background: var(--accent-solid); color: var(--on-accent-solid); border-color: transparent; }
.pdd-sync-btn:disabled { opacity: .5; cursor: default; }
.pdd-mark { width: 100%; padding: 10px; border-radius: 10px; border: none; background: var(--accent-solid); color: var(--on-accent-solid); font-weight: 600; font-size: 14px; cursor: pointer; margin-bottom: 22px; }
.pdd-mark:disabled { opacity: .6; cursor: default; }
.pdd-identify-hint { font-size: 12px; color: var(--muted); margin: -14px 0 18px; }
.pdd-whatsapp { width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px; border-radius: 10px; border: 1px solid var(--border-color); background: transparent; color: var(--text); font-weight: 600; font-size: 14px; cursor: pointer; margin-bottom: 22px; }
.pdd-whatsapp:disabled { opacity: .55; cursor: default; }
.pdd-phone { margin-left: 8px; font-variant-numeric: tabular-nums; }
.pdd-edit-link { margin-left: auto; border: none; background: none; color: var(--c-info); font-size: 11px; letter-spacing: normal; text-transform: none; cursor: pointer; padding: 0; }
.pdd-edit { background: var(--o05); border: 1px solid var(--border-color); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
.pdd-edit-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.pdd-edit-field input { padding: 8px 10px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 13px; }
.pdd-edit-actions { display: flex; justify-content: flex-end; gap: 8px; }
.pdd-edit-btn { padding: 7px 12px; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; font-size: 12.5px; cursor: pointer; color: var(--text); }
.pdd-edit-btn.primary { background: var(--accent-solid); color: var(--on-accent-solid); border-color: transparent; }
.pdd-edit-btn:disabled { opacity: .6; cursor: default; }
.pdd-section-title { font-size: 10.5px; letter-spacing: .07em; color: var(--muted); margin: 18px 0 12px; display: flex; align-items: center; gap: 8px; }
.pdd-summary { background: var(--purple-bg); border: 1px solid var(--purple-border, var(--o12)); border-radius: 12px; padding: 12px 14px; font-size: 13px; line-height: 1.55; color: var(--text2); }
.pdd-count { font-weight: 600; }
.pdd-timeline { display: flex; flex-direction: column; gap: 12px; }
.pdd-tl { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.pdd-tl-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
.pdd-tl-dot.lead { background: var(--c-purple); }
.pdd-tl-dot.customer { background: var(--primary-color); }
.pdd-tl-label { font-weight: 600; }
.pdd-tl-time { color: var(--muted); font-size: 12px; margin-left: auto; }
.pdd-attrs { background: var(--o05); border: 1px solid var(--border-color); border-radius: 12px; padding: 4px 14px; }
.pdd-attr { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 10px 0; border-bottom: 1px solid var(--border-color); font-size: 13px; }
.pdd-attr:last-child { border-bottom: none; }
.pdd-attr-k { color: var(--muted); flex-shrink: 0; }
.pdd-attr-v { text-align: right; font-weight: 500; word-break: break-word; }
.pdd-none { font-size: 13px; color: var(--muted); padding: 6px 0; }
.pdd-convos { display: flex; flex-direction: column; gap: 10px; }
.pdd-convo { background: var(--o05); border: 1px solid var(--border-color); border-radius: 12px; padding: 12px 14px; }
.pdd-convo-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
.pdd-agent { font-size: 12.5px; font-weight: 600; }
.pdd-status { font-size: 11px; color: var(--muted); }
.pdd-snippet { font-size: 13px; color: var(--muted); line-height: 1.5; }
.pdd-convo-date { font-size: 11px; color: var(--muted); margin-top: 6px; }
/* Mobile: full-screen sheet rather than a 440px drawer with a dead sliver */
@media (max-width: 768px) {
.pdd {
width: 100%;
max-width: 100%;
border-left: none;
height: 100vh;
height: 100dvh;
}
.pdd-head {
padding: calc(16px + var(--safe-top)) 16px 16px;
}
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
.pdd-close { width: 44px; height: 44px; }
.pdd-body {
padding: 16px 16px calc(24px + var(--safe-bottom));
}
}
</style>
@@ -0,0 +1,936 @@
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref, onMounted, inject, computed, watch } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { userService } from '@/services/user'
import { meetsPasswordPolicy, validatePassword, type PasswordStrength } from '@/utils/validators'
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
import userAvatar from '@/assets/user.svg'
import type { User } from '@/types/user'
import { isAbsoluteUrl } from '@/utils/avatars'
import { myAvatarUrl } from '@/config/api'
import { useNotificationSettings } from '@/composables/useNotificationSettings'
import { useNotifications } from '@/composables/useNotifications'
import type { NotificationSettings } from '@/services/notification'
const { user } = useAuth()
const {
settings: notificationSettings,
isLoading: notificationsLoading,
isSaving: notificationsSaving,
save: saveNotificationSetting
} = useNotificationSettings()
const { hasPermission: hasPushPermission, enableNotifications } = useNotifications()
// One row per toggle so the markup stays a single loop.
const NOTIFICATION_TOGGLES: { key: keyof NotificationSettings; title: string; desc: string }[] = [
{
key: 'notify_new_chat',
title: '新会话',
desc: '有新会话开始时,包括 AI 正在处理的会话。'
},
{
key: 'notify_chat_transfer',
title: '转接到我所在团队的会话',
desc: 'AI 客服将会话转交给你所在的团队时。'
},
{
key: 'notify_chat_assigned',
title: '分配给我的会话',
desc: '同事将会话重新分配给你时。'
}
]
const profilePicFile = ref<File | null>(null)
const profilePicPreview = ref<string>('')
const formData = ref({
full_name: '',
email: '',
current_password: '',
new_password: '',
confirm_password: ''
})
// Define the fileInput ref with proper typing
const fileInput = ref<HTMLInputElement | null>(null)
const loading = ref(false)
const message = ref('')
const error = ref('')
const passwordTouched = ref(false)
const passwordStrength = ref<PasswordStrength>({
score: 0,
hasMinLength: false,
hasUpperCase: false,
hasLowerCase: false,
hasNumber: false,
hasSpecialChar: false
})
const hasChanges = computed(() => {
if (!user.value) return false
const hasProfileChanges =
formData.value.full_name !== user.value.full_name ||
formData.value.email !== user.value.email
const hasPasswordChanges =
formData.value.new_password ||
formData.value.current_password ||
formData.value.confirm_password
return hasProfileChanges || hasPasswordChanges
})
const avatarInitial = computed(() => {
const name = formData.value.full_name || user.value?.full_name || user.value?.email || ''
return name.trim().charAt(0).toUpperCase() || 'U'
})
const discardChanges = () => {
if (user.value) {
formData.value.full_name = user.value.full_name
formData.value.email = user.value.email
}
formData.value.current_password = ''
formData.value.new_password = ''
formData.value.confirm_password = ''
passwordTouched.value = false
error.value = ''
message.value = ''
}
const userAvatarSrc = computed(() => {
if (profilePicPreview.value) return profilePicPreview.value
if (!user.value?.profile_pic) return userAvatar
// Same reasoning as the header avatar: the stored URL was signed at login and
// expires within the hour, so ask the API to sign a fresh one instead.
return myAvatarUrl(new Date().getTime())
})
onMounted(async () => {
// Load current user data
if (user.value) {
formData.value.full_name = user.value.full_name
formData.value.email = user.value.email
}
})
const handlePasswordInput = (password: string) => {
if (!passwordTouched.value && password.length > 0) {
passwordTouched.value = true
}
passwordStrength.value = validatePassword(password)
}
const handleFileSelect = (event: Event) => {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
const file = input.files[0]
if (!file.type.startsWith('image/')) {
error.value = '请选择图片文件'
return
}
if (file.size > 5 * 1024 * 1024) {
error.value = '图片大小需小于 5MB'
return
}
// Clear any previous error messages
error.value = ''
// Set the file and create preview
profilePicFile.value = file
profilePicPreview.value = URL.createObjectURL(file)
}
const resetFileInput = () => {
// Clear preview and file
profilePicFile.value = null
profilePicPreview.value = ''
// Reset the file input element to allow selecting the same file again
if (fileInput.value) {
fileInput.value.value = ''
}
}
const uploadProfilePic = async () => {
if (!profilePicFile.value) return
loading.value = true
error.value = ''
message.value = ''
try {
const formData = new FormData()
formData.append('file', profilePicFile.value)
await userService.uploadProfilePic(formData)
message.value = '头像已更新'
// Update the current user data with new profile pic and force refresh
if (user.value) {
const updatedUser = userService.getCurrentUser() as User
if (updatedUser && updatedUser.profile_pic) {
user.value = {
...updatedUser,
profile_pic: isAbsoluteUrl(updatedUser.profile_pic) ? updatedUser.profile_pic : `${updatedUser.profile_pic}?t=${new Date().getTime()}`
}
}
}
// Reset the file input
resetFileInput()
} catch (err: any) {
error.value = err.message || '头像上传失败'
} finally {
loading.value = false
}
}
const updateProfile = async () => {
if (!hasChanges.value) {
message.value = '没有需要保存的更改'
return
}
loading.value = true
error.value = ''
message.value = ''
try {
// Create update data only with changed fields
const updateData: Record<string, string> = {}
if (formData.value.full_name !== user.value?.full_name) {
updateData.full_name = formData.value.full_name
}
if (formData.value.email !== user.value?.email) {
updateData.email = formData.value.email
}
if (formData.value.new_password) {
if (formData.value.new_password !== formData.value.confirm_password) {
throw new Error('两次输入的新密码不一致')
}
if (!formData.value.current_password) {
throw new Error('设置新密码前请输入当前密码')
}
if (!meetsPasswordPolicy(passwordStrength.value)) {
throw new Error('密码强度不足')
}
updateData.password = formData.value.new_password
updateData.current_password = formData.value.current_password
}
if (Object.keys(updateData).length === 0) {
message.value = '没有需要保存的更改'
return
}
await userService.updateProfile(updateData)
user.value = userService.getCurrentUser()
message.value = '个人资料已更新'
// Clear password fields
formData.value.current_password = ''
formData.value.new_password = ''
formData.value.confirm_password = ''
} catch (err: any) {
error.value = err.message || '个人资料更新失败'
} finally {
loading.value = false
}
}
// Watch for changes to profilePicFile
watch(profilePicFile, (newFile) => {
if (newFile) {
// Auto-upload when a file is selected
uploadProfilePic()
}
})
const handleProfilePicClick = () => {
if (fileInput.value) {
fileInput.value.click()
}
}
</script>
<template>
<div class="settings-page">
<div class="settings-head">
<h1>个人设置</h1>
<p>管理你的个人资料、密码和通知。</p>
</div>
<form @submit.prevent="updateProfile" class="settings-form">
<!-- Profile card -->
<div class="settings-card">
<h3 class="card-title">个人资料</h3>
<div class="profile-row">
<div class="profile-pic-wrapper" @click="handleProfilePicClick">
<img
v-if="profilePicPreview || user?.profile_pic"
:src="userAvatarSrc"
alt="头像"
class="profile-pic"
/>
<span v-else class="avatar-initial">{{ avatarInitial }}</span>
<div class="profile-pic-overlay">
<font-awesome-icon icon="fa-solid fa-camera" />
<span>更换头像</span>
</div>
</div>
<input
type="file"
accept="image/*"
@change="handleFileSelect"
class="file-input"
ref="fileInput"
>
<div class="profile-pic-meta">
<div class="profile-pic-title">头像</div>
<div class="profile-pic-hint">支持 JPG、PNG 或 GIF,最大 5MB。</div>
</div>
<div class="profile-pic-actions">
<button
type="button"
class="upload-button"
@click="handleProfilePicClick"
:disabled="loading"
>
{{ loading ? '上传中…' : '上传' }}
</button>
</div>
</div>
<div class="field-grid">
<div class="form-group">
<label>姓名</label>
<input
type="text"
v-model="formData.full_name"
placeholder="你的姓名"
>
</div>
<div class="form-group">
<label>邮箱</label>
<input
type="email"
v-model="formData.email"
placeholder="你的邮箱"
class="input-mono"
disabled
>
</div>
</div>
<div class="role-row">
<span class="role-label">角色</span>
<span class="role-badge">{{ user?.role?.name || '用户' }}</span>
<span class="role-note">请联系管理员更改你的角色。</span>
</div>
</div>
<!-- Change password card -->
<div class="settings-card">
<h3 class="card-title">修改密码</h3>
<p class="card-subtitle">至少 8 个字符,并同时包含字母和数字。</p>
<div class="form-group">
<label>当前密码</label>
<input
type="password"
v-model="formData.current_password"
placeholder="••••••••"
>
</div>
<div class="field-grid">
<div class="form-group">
<label>新密码</label>
<input
type="password"
v-model="formData.new_password"
@input="handlePasswordInput(formData.new_password)"
placeholder="输入新密码"
minlength="8"
>
</div>
<div class="form-group">
<label>确认新密码</label>
<input
type="password"
v-model="formData.confirm_password"
placeholder="再次输入新密码"
>
</div>
</div>
<PasswordStrengthMeter
v-if="passwordTouched && formData.new_password"
:strength="passwordStrength"
/>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<!-- Sticky save bar -->
<div v-if="hasChanges" class="save-bar">
<span class="save-bar-text">有尚未保存的更改</span>
<div class="save-bar-actions">
<button type="button" class="discard-button" @click="discardChanges">放弃更改</button>
<button type="submit" class="submit-button" :disabled="loading">
{{ loading ? '保存中…' : '保存更改' }}
</button>
</div>
</div>
<div v-else-if="message" class="save-bar saved-bar">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>
<span class="saved-text">{{ message }}</span>
</div>
</form>
<!-- Notifications: saved on toggle, so deliberately outside the profile form -->
<div class="settings-card notifications-card">
<h3 class="card-title">通知</h3>
<p class="card-subtitle">选择哪些会话事件会向你发送推送通知。</p>
<div v-if="!hasPushPermission" class="permission-notice">
<font-awesome-icon icon="fa-solid fa-bell-slash" class="permission-icon" />
<span class="permission-text">
浏览器尚未获得通知权限,下方设置的通知暂时无法送达。
</span>
<button type="button" class="upload-button" @click="enableNotifications">启用</button>
</div>
<div v-if="notificationsLoading" class="notification-hint">加载偏好设置…</div>
<template v-else-if="notificationSettings">
<div v-for="toggle in NOTIFICATION_TOGGLES" :key="toggle.key" class="notification-row">
<div class="notification-meta">
<div class="notification-title">{{ toggle.title }}</div>
<div class="notification-desc">{{ toggle.desc }}</div>
</div>
<label class="switch">
<input
type="checkbox"
:checked="notificationSettings[toggle.key]"
:disabled="notificationsSaving"
@change="saveNotificationSetting({ [toggle.key]: !notificationSettings[toggle.key] })"
>
<span class="slider" :class="{ enabled: notificationSettings[toggle.key] }"></span>
</label>
</div>
<div class="notification-hint">已自动保存。</div>
</template>
</div>
</div>
</template>
<style scoped>
.settings-page {
max-width: 760px;
margin: 0 auto;
padding: var(--space-lg);
padding-bottom: 80px;
}
.settings-head {
margin-bottom: 26px;
}
.settings-head h1 {
font-family: var(--font-display);
font-weight: 700;
font-size: 30px;
letter-spacing: -0.02em;
color: var(--text);
margin: 0 0 6px;
}
.settings-head p {
font-size: 15px;
color: var(--muted);
margin: 0;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.settings-card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 18px;
padding: 26px;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
color: var(--text);
margin: 0 0 20px;
}
.card-subtitle {
font-size: 13.5px;
color: var(--muted);
margin: -16px 0 20px;
}
/* Notifications card */
.notifications-card {
margin-top: 18px;
}
.permission-notice {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
margin-bottom: 20px;
border: 1px solid var(--o08);
border-radius: var(--radius-btn);
background: var(--bg2);
}
.permission-icon {
color: var(--muted);
flex-shrink: 0;
}
.permission-text {
flex: 1;
font-size: 13.5px;
color: var(--muted);
line-height: 1.45;
}
.notification-row {
display: flex;
align-items: center;
gap: 18px;
padding: 16px 0;
border-top: 1px solid var(--o07);
}
.notification-meta {
flex: 1;
min-width: 0;
}
.notification-title {
font-size: 14.5px;
font-weight: var(--font-weight-medium);
color: var(--text);
}
.notification-desc {
font-size: 13px;
color: var(--muted);
line-height: 1.45;
margin-top: 3px;
}
.notification-hint {
font-size: 12.5px;
color: var(--muted);
padding-top: 14px;
border-top: 1px solid var(--o07);
}
/* Toggle switch (46x26) — matches the agent settings toggles */
.switch {
position: relative;
display: inline-block;
width: 46px;
height: 26px;
flex-shrink: 0;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--toggle-track-off);
transition: background 0.15s;
border-radius: var(--radius-pill);
}
.slider.enabled {
background: var(--toggle-on-teal);
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
top: 3px;
background: var(--toggle-knob);
transition: transform 0.15s;
border-radius: 50%;
}
input:checked + .slider:before {
transform: translateX(20px);
}
/* Avatar row */
.profile-row {
display: flex;
align-items: center;
gap: 18px;
padding-bottom: 22px;
border-bottom: 1px solid var(--o07);
margin-bottom: 22px;
}
.profile-pic-wrapper {
position: relative;
width: 64px;
height: 64px;
flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
background: var(--grad-purple-teal);
display: flex;
align-items: center;
justify-content: center;
}
.profile-pic-wrapper:hover .profile-pic-overlay {
opacity: 1;
}
.avatar-initial {
font-family: var(--font-display);
font-weight: 700;
font-size: 26px;
color: var(--on-accent-solid);
}
.profile-pic {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-pic-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
opacity: 0;
transition: opacity 0.3s ease;
font-size: 10px;
gap: 2px;
}
.profile-pic-overlay svg {
font-size: 1rem;
}
.profile-pic-meta {
flex: 1;
min-width: 0;
}
.profile-pic-title {
font-size: 14px;
font-weight: 600;
color: var(--text2);
margin-bottom: 3px;
}
.profile-pic-hint {
font-size: 12.5px;
color: var(--muted2);
}
.profile-pic-actions {
flex-shrink: 0;
}
.file-input {
opacity: 0;
width: 0;
height: 0;
cursor: pointer;
position: absolute;
}
.upload-button {
padding: 9px 16px;
background: var(--o06);
border: 1px solid var(--o14);
border-radius: 10px;
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: var(--font-sans);
transition: background 0.2s ease;
}
.upload-button:hover:not(:disabled) {
background: var(--o10);
}
.upload-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
/* Inputs */
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group + .form-group,
.form-group + .field-grid,
.field-grid + .form-group {
margin-top: 16px;
}
/* Form-groups inside a 2-col grid align at the top — the grid gap handles spacing */
.field-grid > .form-group + .form-group {
margin-top: 0;
}
.field-grid {
align-items: start;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text3);
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 13px 15px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 11px;
color: var(--text);
font-family: var(--font-sans);
font-size: 14.5px;
outline: none;
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-group input:focus {
border-color: var(--accent-ink);
box-shadow: var(--ring-focus);
}
.form-group input.input-mono,
.form-group input:disabled {
background: var(--o03);
border: 1px solid var(--o08);
color: var(--muted);
font-family: var(--font-mono);
cursor: not-allowed;
}
/* Role badge */
.role-row {
margin-top: 18px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.role-label {
font-size: 13px;
color: var(--muted);
}
.role-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: var(--radius-pill);
background: var(--accent-bg-12);
border: 1px solid var(--accent-border);
color: var(--accent-ink);
font-size: 12.5px;
font-weight: 600;
}
.role-note {
font-size: 12px;
color: var(--faint);
}
/* Sticky save bar */
.save-bar {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
z-index: 50;
display: flex;
align-items: center;
gap: 18px;
padding: 12px 14px 12px 22px;
background: color-mix(in srgb, var(--surface) 96%, transparent);
backdrop-filter: blur(12px);
border: 1px solid var(--o12);
border-radius: 14px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
}
.save-bar-text {
font-size: 13.5px;
color: var(--text3);
}
.save-bar-actions {
display: flex;
gap: 10px;
}
.discard-button {
padding: 10px 16px;
background: transparent;
border: 1px solid var(--o14);
border-radius: 10px;
color: var(--text3);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
font-family: var(--font-sans);
transition: background 0.2s ease;
}
.discard-button:hover {
background: var(--o06);
}
.submit-button {
padding: 10px 20px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 10px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
font-family: var(--font-sans);
transition: filter 0.2s ease;
}
.submit-button:hover:not(:disabled) {
filter: brightness(1.05);
}
.submit-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.saved-bar {
gap: 10px;
padding: 12px 20px;
border-color: var(--accent-border);
color: var(--accent-ink);
}
.saved-text {
font-size: 13.5px;
color: var(--accent-ink);
font-weight: 500;
}
.error-message {
color: var(--error-color);
font-size: 0.875rem;
}
@media (max-width: 768px) {
.settings-page {
padding: var(--space-md);
padding-bottom: 90px;
}
.settings-card {
padding: var(--space-lg);
}
.field-grid {
grid-template-columns: 1fr;
}
.save-bar {
left: var(--space-md);
right: var(--space-md);
transform: none;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
}
</style>
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref } from 'vue'
import { useRouter } from 'vue-router'
import { userService } from '@/services/user'
import { unregisterFCMToken } from '@/services/firebase'
import { authService } from '@/services/auth'
export function useAuth() {
const router = useRouter()
const user = ref(userService.getCurrentUser())
const isLoggingOut = ref(false)
const error = ref('')
const logout = async () => {
try {
isLoggingOut.value = true
// Unregister this device's push token first — only this one, so the
// user's other signed-in devices keep receiving notifications.
await unregisterFCMToken()
// Then proceed with normal logout
await authService.logout()
router.push('/login')
} catch (err) {
error.value = err instanceof Error ? err.message : '退出登录失败'
console.error('Logout failed:', err)
} finally {
isLoggingOut.value = false
}
}
return {
user,
logout,
isLoggingOut,
error,
}
}
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref, type Ref } from 'vue'
// Must match --breakpoint-sm / --breakpoint-md in design-tokens.css and the
// @media (max-width: 768px/1024px) blocks across components — CSS media
// queries can't read JS constants, so changing the cutoff means updating both.
export const MOBILE_BREAKPOINT = 768
export const TABLET_BREAKPOINT = 1024
const track = (query: string): Ref<boolean> => {
if (typeof window === 'undefined' || !window.matchMedia) {
return ref(false)
}
const mql = window.matchMedia(query)
const matches = ref(mql.matches)
// Module-level singleton: listener lives for the app's lifetime, no cleanup needed
mql.addEventListener('change', (e) => {
matches.value = e.matches
})
return matches
}
const isMobile = track(`(max-width: ${MOBILE_BREAKPOINT}px)`)
const isTablet = track(`(max-width: ${TABLET_BREAKPOINT}px)`)
export function useBreakpoint() {
return { isMobile, isTablet }
}
+101
View File
@@ -0,0 +1,101 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
/**
* Composable for currency formatting utilities
*/
/** Decimal places a currency is normally shown with (2 for USD, 0 for JPY). */
const fractionDigits = (currencyCode?: string): number => {
try {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode || 'USD' })
.resolvedOptions().maximumFractionDigits ?? 2
} catch {
return 2 // not an ISO 4217 code
}
}
export function useCurrency() {
// Currency symbol mapping
const currencySymbols: Record<string, string> = {
'USD': '$',
'EUR': '€',
'GBP': '£',
'JPY': '¥',
'INR': '₹',
'CAD': 'CA$',
'AUD': 'A$',
'CNY': '¥',
'CHF': 'CHF',
'SEK': 'kr',
'NOK': 'kr',
'DKK': 'kr',
'NZD': 'NZ$',
'SGD': 'S$',
'HKD': 'HK$',
'KRW': '₩',
'MXN': 'MX$',
'BRL': 'R$',
'ZAR': 'R',
'RUB': '₽',
'TRY': '₺',
'THB': '฿',
'PLN': 'zł',
'AED': 'د.إ',
'SAR': '﷼',
'ILS': '₪',
'MYR': 'RM'
};
/**
* Format price with proper currency symbol
* @param price - The price value
* @param currencyCode - ISO 4217 currency code (e.g., 'USD', 'EUR')
* @returns Formatted price string with currency symbol
*/
const formatCurrency = (price: string | number | null | undefined, currencyCode?: string): string => {
if (!price && price !== 0) return '';
// Get currency symbol, fallback to currency code or empty string
const symbol = currencyCode ? (currencySymbols[currencyCode] || currencyCode) : '';
// Numbers get the currency's usual decimals ($600.00, ¥600), so a price
// that arrives as "600.0" or 600 doesn't render as "$600.0" or "$600".
const amount = typeof price === 'number' ? price : Number(price)
const priceValue = Number.isFinite(amount)
? amount.toLocaleString('en-US', {
minimumFractionDigits: fractionDigits(currencyCode),
maximumFractionDigits: fractionDigits(currencyCode)
})
: String(price)
return symbol ? `${symbol}${priceValue}` : priceValue;
};
/**
* Get currency symbol for a given currency code
* @param currencyCode - ISO 4217 currency code
* @returns Currency symbol or code if not found
*/
const getCurrencySymbol = (currencyCode: string): string => {
return currencySymbols[currencyCode] || currencyCode;
};
return {
formatCurrency,
getCurrencySymbol,
currencySymbols
};
}
@@ -0,0 +1,244 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref, readonly, defineComponent, defineAsyncComponent, h, computed } from 'vue'
import type { Component } from 'vue'
// Create a proper Vue component for the fallback
const NotAvailableComponent = defineComponent({
name: 'NotAvailable',
setup() {
return () => h('div', '开源版本不提供此功能')
},
})
// Check for enterprise module
const enterpriseModules = import.meta.glob([
'/src/modules/enterprise/views/SignupView.vue',
'/src/modules/enterprise/composables/useSubscriptionStore.ts',
'/src/modules/enterprise/router/guards/subscription.ts',
])
const hasEnterpriseModule = Object.keys(enterpriseModules).length > 0
interface Plan {
type: string
name: string
[key: string]: any
}
interface SubscriptionPlan {
plan: Plan
message_count?: number
message_limit?: number
[key: string]: any
}
interface SubscriptionStore {
currentPlan: SubscriptionPlan | null
isLoadingPlan: boolean
isInTrial: boolean
trialDaysLeft: number
fetchCurrentPlan: () => Promise<void>
}
interface MessageLimitStatus {
type: 'warning' | 'error'
message: string
percentage: number
}
interface SubscriptionGuard {
subscriptionGuard: (to: any, from: any, next: any) => void
}
type EnterpriseModule = {
default?: Component
subscriptionStore?: SubscriptionStore
subscriptionGuard?: (to: any, from: any, next: any) => void
initShopifyApp?: () => any
getSessionToken?: (app: any) => Promise<string>
useShopifyIntegration?: (agentId: string) => any
setupService?: { getProgress: () => Promise<{ step: string; agent_id: string | null; widget_id: string | null }> }
}
// Create a mapping of module paths to their direct imports
// These should be used with dynamic import() directly, not with glob
const moduleImports = {
signupView: '/src/modules/enterprise/views/SignupView.vue',
subscriptionView: '/src/modules/enterprise/views/SubscriptionView.vue',
billingSetupView: '/src/modules/enterprise/views/BillingSetupView.vue',
subscriptionStore: '/src/modules/enterprise/composables/useSubscriptionStore.ts',
subscriptionGuard: '/src/modules/enterprise/router/guards/subscription.ts',
// Explore is retired (its route is commented out in router/index.ts)
// exploreView: '/src/modules/enterprise/views/ExploreView.vue',
shopifySessionTokenBounce: '/src/modules/enterprise/views/ShopifySessionTokenBouncePage.vue',
shopifyConnect: '/src/modules/enterprise/views/ShopifyConnectAccountView.vue',
shopifyAuthComplete: '/src/modules/enterprise/views/ShopifyAuthCompleteView.vue',
shopifyAgentSelection: '/src/modules/enterprise/views/ShopifyAgentSelectionView.vue',
shopifyAgentManagement: '/src/modules/enterprise/views/ShopifyAgentManagementView.vue',
shopifyInbox: '/src/modules/enterprise/views/ShopifyInboxView.vue',
shopifyPricing: '/src/modules/enterprise/views/ShopifyPricingView.vue',
shopifyAppBridge: '/src/modules/enterprise/plugins/shopifyAppBridge.ts',
shopifyAppBridgeUtilities: '/src/modules/enterprise/plugins/shopifyAppBridgeUtilities.ts',
shopifyIntegration: '/src/modules/enterprise/composables/useShopifyIntegration.ts',
// Promo offer surfaces (render nothing in the open-source build)
promoSurfaces: '/src/modules/enterprise/components/promo/PromoSurfaces.vue',
promoHeaderPill: '/src/modules/enterprise/components/promo/PromoHeaderPill.vue',
promoUsageCard: '/src/modules/enterprise/components/promo/PromoUsageCard.vue',
promoGateNotice: '/src/modules/enterprise/components/promo/PromoGateNotice.vue',
promoLimitToast: '/src/modules/enterprise/components/promo/PromoLimitToast.vue',
// Help button for the hosted support chat (renders nothing in open source)
supportChatButton: '/src/modules/enterprise/components/support/SupportChatButton.vue',
// External apps driving channel conversations (settings page)
appIntegrationsView: '/src/modules/enterprise/views/AppIntegrationsView.vue',
// Setup progress: the "Finish setup" bar, where the widget is live, and the
// server-side step the wizard resumes at (nothing / null in open source)
setupProgressBar: '/src/modules/enterprise/components/setup/SetupProgressBar.vue',
widgetInstallStatus: '/src/modules/enterprise/components/setup/WidgetInstallStatus.vue',
setupService: '/src/modules/enterprise/utils/setupService.ts',
}
/** Renders nothing: the open-source stand-in for an enterprise-only surface. */
const EmptyComponent = defineComponent({ name: 'EnterpriseEmpty', setup: () => () => null })
// One glob for the whole module (a literal object per evaluation otherwise),
// and one async-component definition per path for the life of the app.
const allEnterpriseModules = import.meta.glob<EnterpriseModule>([
'/src/modules/enterprise/**/*.vue',
'/src/modules/enterprise/**/*.ts',
// Tests live in the enterprise repo too; they are not part of the app bundle.
'!/src/modules/enterprise/e2e/**',
'!/src/modules/enterprise/__tests__/**',
])
const asyncComponents = new Map<string, Component>()
// Default subscription state
const defaultSubscriptionState: SubscriptionStore = {
currentPlan: null,
isLoadingPlan: false,
isInTrial: false,
trialDaysLeft: 0,
fetchCurrentPlan: () => Promise.resolve(),
}
export const useEnterpriseFeatures = () => {
const subscriptionStore = ref<SubscriptionStore>(defaultSubscriptionState)
const showMessageLimitWarning = computed(() => {
const plan = subscriptionStore.value.currentPlan
if (!plan?.plan) return false
const messageCount = plan.message_count || 0
const messageLimit = plan.message_limit
if (!messageLimit) return false
return messageCount >= messageLimit * 0.9
})
const messageLimitStatus = computed<MessageLimitStatus | null>(() => {
const plan = subscriptionStore.value.currentPlan
if (!plan?.plan) return null
const messageCount = plan.message_count || 0
const messageLimit = plan.message_limit
if (!messageLimit) return null
const usagePercentage = (messageCount / messageLimit) * 100
if (messageCount >= messageLimit) {
return {
type: 'error',
message: '消息数已达上限!切换到你自己的模型或升级套餐。',
percentage: 100,
}
} else if (usagePercentage >= 90) {
return {
type: 'warning',
message: `消息数即将达到上限(${Math.round(usagePercentage)}%),建议升级套餐。`,
percentage: usagePercentage,
}
}
return null
})
const modules = allEnterpriseModules
// Check if any enterprise modules exist
const hasEnterpriseModule = Object.keys(modules).length > 0
const loadModule = async (modulePath: string): Promise<EnterpriseModule | null> => {
if (!hasEnterpriseModule) {
return null
}
try {
if (modules[modulePath]) {
const module = await modules[modulePath]()
return module
}
return null
} catch (error) {
console.warn(`Failed to load enterprise module: ${modulePath}`, error)
return null
}
}
/**
* An enterprise component as an async component, or an empty one when the
* module is absent - so open-source templates can mount promo surfaces
* unconditionally without importing enterprise code.
*/
const enterpriseComponent = (modulePath: string): Component => {
if (!hasEnterpriseModule || !modules[modulePath]) return EmptyComponent
let component = asyncComponents.get(modulePath)
if (!component) {
component = defineAsyncComponent(async () => {
const module = await loadModule(modulePath)
return module?.default ?? EmptyComponent
})
asyncComponents.set(modulePath, component)
}
return component
}
const initializeSubscriptionStore = async () => {
if (hasEnterpriseModule) {
try {
const module = await loadModule(moduleImports.subscriptionStore)
if (module?.subscriptionStore) {
subscriptionStore.value = module.subscriptionStore
}
} catch (error) {
console.warn('Enterprise subscription store not available:', error)
}
}
}
return {
hasEnterpriseModule,
subscriptionStore: readonly(subscriptionStore),
initializeSubscriptionStore,
loadModule,
moduleImports,
enterpriseComponent,
NotAvailableComponent,
showMessageLimitWarning,
messageLimitStatus,
}
}
@@ -0,0 +1,231 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref, watch } from 'vue'
import api from '@/services/api'
import { validatePassword } from '@/utils/validators'
import type { AxiosError } from 'axios'
interface ValidationError {
type: string
loc: (string | number)[]
msg: string
input?: any
ctx?: Record<string, any>
}
interface ErrorResponse {
detail: string | ValidationError[]
}
export function useForgotPassword() {
const isLoading = ref(false)
const error = ref('')
const success = ref('')
const currentStep = ref(1) // 1: email, 2: otp + new password
const email = ref('')
const otp = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const passwordValidation = ref({
score: 0,
hasMinLength: false,
hasUpperCase: false,
hasLowerCase: false,
hasNumber: false,
hasSpecialChar: false
})
// Live update password validation as user types
watch(newPassword, (val) => {
passwordValidation.value = validatePassword(val)
})
/**
* Parse validation errors from backend response
*/
const parseValidationErrors = (detail: string | ValidationError[]): string => {
if (typeof detail === 'string') {
return detail
}
if (Array.isArray(detail) && detail.length > 0) {
// Get the first validation error and return a user-friendly message
const firstError = detail[0]
// Handle email validation errors specifically
if (firstError.loc.includes('email')) {
if (firstError.msg.includes('not a valid email address')) {
return firstError.ctx?.reason || '请输入有效的邮箱地址'
}
return '请输入有效的邮箱地址'
}
// Return the error message for other validation errors
return firstError.msg
}
return '输入无效'
}
/**
* Request password reset OTP
*/
const requestPasswordReset = async () => {
try {
isLoading.value = true
error.value = ''
success.value = ''
const response = await api.post('/enterprise/signup/forgot-password/request', {
email: email.value
})
success.value = response.data.message || '验证码已发送到你的邮箱'
currentStep.value = 2
return true
} catch (err) {
const axiosError = err as AxiosError<ErrorResponse>
const detail = axiosError.response?.data?.detail
if (detail) {
error.value = parseValidationErrors(detail)
} else {
error.value = '发送验证码失败'
}
console.error('Password reset request error:', err)
return false
} finally {
isLoading.value = false
}
}
/**
* Verify OTP and reset password
*/
const verifyAndResetPassword = async () => {
try {
isLoading.value = true
error.value = ''
success.value = ''
// Validate passwords match
if (newPassword.value !== confirmPassword.value) {
error.value = '两次输入的密码不一致'
return false
}
// Validate password strength using shared validator
passwordValidation.value = validatePassword(newPassword.value)
if (passwordValidation.value.score < 5) {
error.value = '密码至少 8 位,且需包含大写字母、小写字母、数字和特殊字符'
return false
}
const response = await api.post('/enterprise/signup/forgot-password/verify', {
email: email.value,
otp: otp.value,
new_password: newPassword.value
})
success.value = response.data.message || '密码重置成功'
return true
} catch (err) {
const axiosError = err as AxiosError<ErrorResponse>
const detail = axiosError.response?.data?.detail
let errorMessage: string
if (detail) {
errorMessage = parseValidationErrors(detail)
} else {
errorMessage = '重置密码失败'
}
// Directly show backend-provided remaining attempts message when present
if (errorMessage.includes('Invalid code') && errorMessage.includes('attempt')) {
error.value = errorMessage
return false
}
// Map terminal states to the exact UX and reset to step 1
if (
errorMessage.includes('Maximum attempts reached') ||
errorMessage.includes('No active OTP found') ||
errorMessage.includes('OTP has expired')
) {
error.value = '验证码尝试次数已达上限,请重新发起找回密码'
setTimeout(() => {
goBackToEmailStep()
}, 1500)
return false
}
// Fallback
error.value = errorMessage
console.error('Password reset verification error:', err)
return false
} finally {
isLoading.value = false
}
}
/**
* Reset the form to initial state
*/
const resetForm = () => {
email.value = ''
otp.value = ''
newPassword.value = ''
confirmPassword.value = ''
error.value = ''
success.value = ''
currentStep.value = 1
isLoading.value = false
}
/**
* Go back to previous step
*/
const goBackToEmailStep = () => {
currentStep.value = 1
otp.value = ''
newPassword.value = ''
confirmPassword.value = ''
error.value = ''
success.value = ''
}
return {
// State
isLoading,
error,
success,
currentStep,
email,
otp,
newPassword,
confirmPassword,
passwordValidation,
// Methods
requestPasswordReset,
verifyAndResetPassword,
resetForm,
goBackToEmailStep
}
}
+185
View File
@@ -0,0 +1,185 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref } from 'vue'
import type { User, UserGroup } from '@/types/user'
import { listGroups, createGroup, updateGroup, deleteGroup, addUserToGroup, removeUserFromGroup } from '@/services/groups'
import { listUsers } from '@/services/users'
import { toast } from 'vue-sonner'
export function useGroups() {
const groups = ref<UserGroup[]>([])
const users = ref<User[]>([])
const loading = ref(false)
const error = ref('')
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showMembersModal = ref(false)
const showDeleteModal = ref(false)
const selectedGroup = ref<UserGroup | null>(null)
const selectedUsers = ref<string[]>([])
const fetchGroups = async () => {
try {
loading.value = true
groups.value = await listGroups()
} catch (err) {
console.error('Failed to load groups:', err)
error.value = '加载分组失败'
} finally {
loading.value = false
}
}
const fetchUsers = async () => {
try {
const response = await listUsers()
users.value = response
} catch (err) {
console.error('Failed to load users:', err)
toast.error('加载用户失败', {
duration: 4000,
closeButton: true
})
}
}
const handleCreateGroup = async (groupData: Partial<UserGroup>) => {
try {
loading.value = true
const newGroup = await createGroup(groupData)
groups.value.unshift(newGroup)
showCreateModal.value = false
toast.success('成功', {
description: '分组已创建',
duration: 4000,
closeButton: true
})
} catch (err) {
console.error('Error creating group:', err)
toast.error('出错了', {
description: '创建分组失败',
duration: 4000,
closeButton: true
})
} finally {
loading.value = false
}
}
const handleEditGroup = (group: UserGroup) => {
selectedGroup.value = group
showEditModal.value = true
}
const handleUpdateGroup = async (groupData: Partial<UserGroup>) => {
if (!selectedGroup.value) return
try {
loading.value = true
const updatedGroup = await updateGroup(selectedGroup.value.id, groupData)
const index = groups.value.findIndex(g => g.id === updatedGroup.id)
if (index !== -1) {
groups.value[index] = updatedGroup
}
showEditModal.value = false
toast.success('分组已更新', {
duration: 4000,
closeButton: true
})
} catch (err) {
console.error('Error updating group:', err)
toast.error('更新分组失败', {
duration: 4000,
closeButton: true
})
} finally {
loading.value = false
}
}
const handleManageMembers = async (group: UserGroup) => {
selectedGroup.value = group
selectedUsers.value = group.users?.map(u => u.id) || []
await fetchUsers()
showMembersModal.value = true
}
const handleUserSelection = async (userId: string, checked: boolean) => {
if (!selectedGroup.value) return
try {
loading.value = true
if (checked) {
await addUserToGroup(selectedGroup.value.id, userId)
selectedUsers.value.push(userId)
} else {
await removeUserFromGroup(selectedGroup.value.id, userId)
selectedUsers.value = selectedUsers.value.filter(id => id !== userId)
}
await fetchGroups() // Refresh groups to get updated members
} catch (err) {
console.error('Error managing members:', err)
toast.error(checked ? '添加成员失败' : '移除成员失败')
} finally {
loading.value = false
}
}
const handleDeleteGroup = (group: UserGroup) => {
selectedGroup.value = group
showDeleteModal.value = true
}
const handleDeleteConfirm = async () => {
if (!selectedGroup.value) return
try {
loading.value = true
await deleteGroup(selectedGroup.value.id)
groups.value = groups.value.filter(g => g.id !== selectedGroup.value?.id)
showDeleteModal.value = false
toast.success('分组已删除')
} catch (err) {
console.error('Error deleting group:', err)
toast.error('删除分组失败')
} finally {
loading.value = false
}
}
return {
groups,
users,
loading,
error,
showCreateModal,
showEditModal,
showMembersModal,
showDeleteModal,
selectedGroup,
selectedUsers,
fetchGroups,
fetchUsers,
handleCreateGroup,
handleEditGroup,
handleUpdateGroup,
handleManageMembers,
handleUserSelection,
handleDeleteGroup,
handleDeleteConfirm
}
}
@@ -0,0 +1,148 @@
/*
Copyright 2024-2026 ChatterMate
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 { userService } from '@/services/user'
const STORAGE_KEY = 'chattermate:onboarding:v1'
export const ONBOARDING_STEPS = ['Create', 'Teach', 'Test', 'Launch'] as const
export type OnboardingStepIndex = 0 | 1 | 2 | 3
export interface OnboardingRecord {
skipped: boolean
currentStep: OnboardingStepIndex
completedSteps: number[]
agentId: string | null
agentName: string | null
widgetId: string | null
updatedAt: number
}
type OnboardingStore = Record<string, OnboardingRecord>
const emptyRecord = (): OnboardingRecord => ({
skipped: false,
currentStep: 0,
completedSteps: [],
agentId: null,
agentName: null,
widgetId: null,
updatedAt: Date.now(),
})
function readStore(): OnboardingStore {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? (parsed as OnboardingStore) : {}
} catch {
return {}
}
}
function writeStore(store: OnboardingStore) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
} catch (err) {
console.warn('Failed to persist onboarding state:', err)
}
}
/**
* localStorage-backed onboarding state machine, keyed by organization id.
* Completion is backend-authoritative (org has >=1 agent); this only tracks
* skip + in-run progress + the resume-checklist banner ticks.
*/
export function useOnboardingState() {
const resolveOrgId = (orgId?: string): string => {
return orgId || userService.getCurrentUser()?.organization_id || ''
}
const get = (orgId?: string): OnboardingRecord => {
const id = resolveOrgId(orgId)
if (!id) return emptyRecord()
const store = readStore()
return store[id] ? { ...emptyRecord(), ...store[id] } : emptyRecord()
}
const patch = (orgId: string | undefined, partial: Partial<OnboardingRecord>): OnboardingRecord => {
const id = resolveOrgId(orgId)
if (!id) return emptyRecord()
const store = readStore()
const next: OnboardingRecord = {
...emptyRecord(),
...store[id],
...partial,
updatedAt: Date.now(),
}
store[id] = next
writeStore(store)
return next
}
const isSkipped = (orgId?: string): boolean => get(orgId).skipped
const skip = (orgId?: string): OnboardingRecord => patch(orgId, { skipped: true })
const clear = (orgId?: string) => {
const id = resolveOrgId(orgId)
if (!id) return
const store = readStore()
delete store[id]
writeStore(store)
}
const goTo = (orgId: string | undefined, step: OnboardingStepIndex): OnboardingRecord =>
patch(orgId, { currentStep: step })
const completeStep = (
orgId: string | undefined,
step: OnboardingStepIndex,
nextStep?: OnboardingStepIndex,
): OnboardingRecord => {
const record = get(orgId)
const completedSteps = Array.from(new Set([...record.completedSteps, step])).sort((a, b) => a - b)
return patch(orgId, {
completedSteps,
currentStep: nextStep ?? record.currentStep,
})
}
/**
* An unfinished run worth surfacing in the resume banner: an agent was
* created but the user hasn't seen every step, and they didn't skip.
*/
const hasUnfinishedRun = (orgId?: string): boolean => {
const record = get(orgId)
if (record.skipped) return false
if (!record.agentId) return false
return record.completedSteps.length < ONBOARDING_STEPS.length
}
return {
ONBOARDING_STEPS,
get,
patch,
isSkipped,
skip,
clear,
goTo,
completeStep,
hasUnfinishedRun,
resolveOrgId,
}
}
@@ -0,0 +1,174 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { organizationService } from '@/services/organization'
import type { Organization, BusinessHoursDict } from '@/types/organization'
export function useOrganizationSettings() {
const { user } = useAuth()
const defaultBusinessHours = (): BusinessHoursDict => ({
monday: { start: '09:00', end: '17:00', enabled: true },
tuesday: { start: '09:00', end: '17:00', enabled: true },
wednesday: { start: '09:00', end: '17:00', enabled: true },
thursday: { start: '09:00', end: '17:00', enabled: true },
friday: { start: '09:00', end: '17:00', enabled: true },
saturday: { start: '09:00', end: '17:00', enabled: false },
sunday: { start: '09:00', end: '17:00', enabled: false }
})
// Server-stored business_hours may be empty or missing days; merge over the
// full default so every day key is present (presets + day-rows assume this).
const normalizeBusinessHours = (raw: unknown): BusinessHoursDict => ({
...defaultBusinessHours(),
...(raw && typeof raw === 'object' ? raw as Partial<BusinessHoursDict> : {})
})
const formData = ref({
name: '',
domain: '',
timezone: '',
business_hours: defaultBusinessHours(),
settings: {} as Record<string, any>
})
// Add helper functions for business hours
const days = [
{ key: 'monday', label: '星期一' },
{ key: 'tuesday', label: '星期二' },
{ key: 'wednesday', label: '星期三' },
{ key: 'thursday', label: '星期四' },
{ key: 'friday', label: '星期五' },
{ key: 'saturday', label: '星期六' },
{ key: 'sunday', label: '星期日' }
] as const
const timeOptions = Array.from({ length: 24 * 4 }, (_, i) => {
const hour = Math.floor(i / 4)
const minute = (i % 4) * 15
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
})
const loading = ref(false)
const message = ref('')
const error = ref('')
const stats = ref<any>(null)
const originalData = ref({
name: '',
domain: '',
timezone: '',
business_hours: {} as BusinessHoursDict,
settings: {}
})
const hasChanges = computed(() => {
return formData.value.name !== originalData.value.name ||
formData.value.domain !== originalData.value.domain ||
formData.value.timezone !== originalData.value.timezone ||
JSON.stringify(formData.value.business_hours) !== JSON.stringify(originalData.value.business_hours) ||
JSON.stringify(formData.value.settings) !== JSON.stringify(originalData.value.settings)
})
const loadOrganizationData = async () => {
if (user.value?.organization_id) {
try {
loading.value = true
const [org, orgStats] = await Promise.all([
organizationService.getOrganization(user.value.organization_id),
organizationService.getOrganizationStats(user.value.organization_id)
])
formData.value = {
name: org.name,
domain: org.domain,
timezone: org.timezone,
business_hours: normalizeBusinessHours(org.business_hours),
settings: org.settings ? JSON.parse(JSON.stringify(org.settings)) : {}
}
originalData.value = JSON.parse(JSON.stringify(formData.value))
stats.value = orgStats
} catch (err: any) {
error.value = err.message || '加载组织数据失败'
} finally {
loading.value = false
}
}
}
const updateOrganization = async () => {
if (!user.value?.organization_id || !hasChanges.value) {
message.value = '没有需要保存的更改'
return
}
loading.value = true
error.value = ''
message.value = ''
try {
const updateData: Partial<Organization> = {}
if (formData.value.name !== originalData.value.name) {
updateData.name = formData.value.name
}
if (formData.value.domain !== originalData.value.domain) {
updateData.domain = formData.value.domain
}
if (formData.value.timezone !== originalData.value.timezone) {
updateData.timezone = formData.value.timezone
}
if (JSON.stringify(formData.value.business_hours) !== JSON.stringify(originalData.value.business_hours)) {
updateData.business_hours = JSON.parse(JSON.stringify(formData.value.business_hours))
}
if (JSON.stringify(formData.value.settings) !== JSON.stringify(originalData.value.settings)) {
updateData.settings = JSON.parse(JSON.stringify(formData.value.settings))
}
await organizationService.updateOrganization(user.value.organization_id, updateData)
message.value = '组织信息已更新'
originalData.value = JSON.parse(JSON.stringify(formData.value))
} catch (err: any) {
// Show the API's own message (e.g. a domain another org already uses)
const detail = err.response?.data?.detail
error.value = typeof detail === 'string' ? detail : err.message || '更新组织信息失败'
} finally {
loading.value = false
}
}
return {
formData,
loading,
message,
error,
stats,
hasChanges,
days,
timeOptions,
loadOrganizationData,
updateOrganization
}
}
+147
View File
@@ -0,0 +1,147 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref } from 'vue'
import type { Role } from '@/types/user'
import { listRoles, createRole, updateRole, deleteRole } from '@/services/roles'
import { toast } from 'vue-sonner'
export function useRoles() {
const roles = ref<Role[]>([])
const loading = ref(false)
const error = ref('')
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteModal = ref(false)
const selectedRole = ref<Role | null>(null)
const deleteError = ref('')
const openCreateModal = () => {
selectedRole.value = null
showCreateModal.value = true
}
const fetchRoles = async () => {
try {
loading.value = true
roles.value = await listRoles()
} catch (err) {
console.error('Failed to load roles:', err)
error.value = '加载角色失败'
} finally {
loading.value = false
}
}
const handleCreateRole = async (roleData: Partial<Role>) => {
try {
console.log('Creating role:', roleData)
loading.value = true
const newRole = await createRole(roleData)
roles.value.unshift(newRole)
showCreateModal.value = false
toast.success('角色已创建', {
duration: 4000,
closeButton: true
})
} catch (err) {
console.error('Error creating role:', err)
toast.error('创建角色失败', {
duration: 4000,
closeButton: true
})
} finally {
loading.value = false
}
}
const handleEditRole = (role: Role) => {
console.log('Editing role:', role)
selectedRole.value = role
showEditModal.value = true
}
const handleUpdateRole = async (roleData: Partial<Role>) => {
if (!selectedRole.value) return
try {
loading.value = true
const updatedRole = await updateRole(selectedRole.value.id, roleData)
const index = roles.value.findIndex(r => r.id === updatedRole.id)
if (index !== -1) {
roles.value[index] = updatedRole
}
showEditModal.value = false
toast.success('角色已更新', {
duration: 4000,
closeButton: true
})
} catch (err) {
console.error('Error updating role:', err)
toast.error('更新角色失败', {
duration: 4000,
closeButton: true
})
} finally {
loading.value = false
}
}
const handleDeleteRole = (role: Role) => {
selectedRole.value = role
showDeleteModal.value = true
deleteError.value = ''
}
const confirmDeleteRole = async () => {
if (!selectedRole.value) return
try {
loading.value = true
deleteError.value = ''
await deleteRole(selectedRole.value.id)
roles.value = roles.value.filter(r => r.id !== selectedRole.value?.id)
showDeleteModal.value = false
toast.success('角色已删除', {
duration: 4000,
closeButton: true
})
} catch (err: any) {
console.error('Error deleting role:', err)
deleteError.value = err.response?.data?.detail || '删除角色失败'
} finally {
loading.value = false
}
}
return {
roles,
loading,
error,
deleteError,
showCreateModal,
showEditModal,
showDeleteModal,
selectedRole,
fetchRoles,
openCreateModal,
handleCreateRole,
handleEditRole,
handleUpdateRole,
handleDeleteRole,
confirmDeleteRole
}
}
+79
View File
@@ -0,0 +1,79 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref, computed } from 'vue'
import { THEME_COLORS } from '@/config/themeColors'
export type ThemeMode = 'dark' | 'light' | 'system'
const STORAGE_KEY = 'cm-theme'
const stored = (typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY)) || 'dark'
const mode = ref<ThemeMode>(stored === 'light' || stored === 'system' ? (stored as ThemeMode) : 'dark')
const prefersDark = (): boolean =>
typeof window !== 'undefined' &&
!!window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
// Resolve the chosen mode to the concrete theme applied to the DOM
const resolve = (m: ThemeMode): 'dark' | 'light' =>
m === 'system' ? (prefersDark() ? 'dark' : 'light') : m
const apply = (m: ThemeMode) => {
if (typeof document !== 'undefined') {
const resolved = resolve(m)
document.documentElement.setAttribute('data-theme', resolved)
// Keep the browser/PWA chrome color in sync with the page background
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', THEME_COLORS[resolved])
}
}
// Apply on first import (initial load) and follow OS changes while in system mode
apply(mode.value)
if (typeof window !== 'undefined' && window.matchMedia) {
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (mode.value === 'system') apply('system')
})
}
export function useTheme() {
const setTheme = (value: ThemeMode) => {
mode.value = value
localStorage.setItem(STORAGE_KEY, value)
apply(value)
}
// Cycle: dark → light → system → dark
const toggle = () => {
const next: ThemeMode =
mode.value === 'dark' ? 'light' : mode.value === 'light' ? 'system' : 'dark'
setTheme(next)
}
return {
mode,
theme: mode,
toggle,
setTheme,
isDark: computed(() => resolve(mode.value) === 'dark'),
isSystem: computed(() => mode.value === 'system'),
}
}
+119
View File
@@ -0,0 +1,119 @@
/*
Copyright 2024-2026 ChatterMate
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 { reactive, watch, onUnmounted, type Ref } from 'vue'
/**
* Client-side "streaming" reveal for agent/bot replies. The full message already
* arrived over the socket; this composable reveals it character-by-character with
* a single shared timer (no per-character re-mount) so it reads like live typing.
*
* Only messages flagged `stream: true` animate — history and user/system messages
* render in full immediately. Honors `prefers-reduced-motion`.
*/
// Reveal cadence (ms) — slightly slower after spaces, matching the design comp.
const CHAR_DELAY = 13
const SPACE_DELAY = 24
interface StreamState {
shown: number
done: boolean
}
export function useTypewriter(messages: Ref<any[]>, onReveal?: () => void) {
// Keyed by message array index.
const state = reactive<Record<number, StreamState>>({})
const queue: number[] = []
let timer: ReturnType<typeof setTimeout> | null = null
const reduceMotion = typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
const scheduleNext = (delay: number) => {
if (timer || queue.length === 0) return
timer = setTimeout(tick, delay)
}
const tick = () => {
timer = null
const idx = queue[0]
if (idx === undefined) return
const msg = messages.value[idx]
const st = state[idx]
const full: string = (msg?.message ?? '') as string
if (!st || !msg) {
queue.shift()
scheduleNext(0)
return
}
if (st.shown >= full.length) {
st.done = true
queue.shift()
scheduleNext(0)
return
}
st.shown += 1
const ch = full[st.shown - 1]
onReveal?.()
scheduleNext(ch === ' ' ? SPACE_DELAY : CHAR_DELAY)
}
// Register any newly-appended streamable messages.
watch(() => messages.value.length, (len, prev) => {
// The list shrank/reset (e.g. a new conversation) — clear index-keyed state
// so old entries can't bleed onto reused indices.
if (prev !== undefined && len < prev) {
Object.keys(state).forEach(k => { delete state[Number(k)] })
queue.length = 0
}
for (let i = prev ?? 0; i < len; i++) {
const m = messages.value[i]
if (!m || !m.stream || i in state) continue
const full: string = (m.message ?? '') as string
if (reduceMotion || !full) {
state[i] = { shown: full.length, done: true }
} else {
state[i] = { shown: 0, done: false }
queue.push(i)
}
}
scheduleNext(0)
})
/** Visible (partial) text for a message at `index`; non-streamed → full text. */
const displayText = (index: number, fullText: string): string => {
const st = state[index]
return st ? fullText.slice(0, st.shown) : fullText
}
/** Whether the message at `index` is still revealing (caret should show). */
const isStreaming = (index: number): boolean => {
const st = state[index]
return !!st && !st.done
}
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
return { displayText, isStreaming }
}
@@ -0,0 +1,56 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref, watch, onMounted, onUnmounted, type Ref } from 'vue'
import { postToHost } from '../webclient/host-bridge'
/**
* Tracks unread agent/bot messages that arrive while the widget is minimized and
* reports the running count to the embedder (which renders it on the launcher
* badge). The embedder drives visibility via `WIDGET_VISIBILITY` messages and
* shows the nudge count until the chat is first opened, then this unread count.
*/
export function useUnreadBadge(messages: Ref<any[]>) {
const visible = ref(true)
let unread = 0
const post = () => {
postToHost({ type: 'UNREAD_COUNT', count: unread })
}
const onMessage = (e: MessageEvent) => {
if (e?.data?.type !== 'WIDGET_VISIBILITY') return
visible.value = !!e.data.open
// Opening the chat clears the unread count.
if (visible.value && unread !== 0) {
unread = 0
post()
}
}
// Count agent/bot replies that land while the chat is closed.
watch(() => messages.value.length, (len, prev) => {
if (len <= (prev ?? 0) || visible.value) return
const m = messages.value[len - 1]
if (m && (m.message_type === 'bot' || m.message_type === 'agent')) {
unread += 1
post()
}
})
onMounted(() => window.addEventListener('message', onMessage))
onUnmounted(() => window.removeEventListener('message', onMessage))
}
+202
View File
@@ -0,0 +1,202 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref } from 'vue'
import type { ChatScopeFields, User } from '@/types/user'
import { listUsers, createUser, updateUser, deleteUser, resetUserPassword } from '@/services/users'
import { toast } from 'vue-sonner'
export function useUsers() {
const users = ref<User[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const showEditModal = ref(false)
const showDeleteModal = ref(false)
const selectedUser = ref<User | null>(null)
const showCreateModal = ref(false)
const showResetPasswordModal = ref(false)
const resettingPassword = ref(false)
const fetchUsers = async () => {
try {
loading.value = true
users.value = await listUsers()
} catch (err: any) {
error.value = '加载用户失败:' + err.response.data.detail
console.error('Error loading users:', err.response.data.detail)
} finally {
loading.value = false
}
}
const handleEditUser = (user: User) => {
selectedUser.value = user
showEditModal.value = true
}
const handleUpdateUser = async (userData: Partial<User> & ChatScopeFields) => {
if (!selectedUser.value) return
try {
loading.value = true
error.value = null
const updatedUser = await updateUser(selectedUser.value.id, userData)
// Update local users list
const index = users.value.findIndex(u => u.id === updatedUser.id)
if (index !== -1) {
users.value[index] = updatedUser
}
showEditModal.value = false
selectedUser.value = null
toast.success('成功', {
description: '用户已更新',
duration: 4000,
closeButton: true
})
} catch (err: any) {
error.value = '更新用户失败'
console.error('Error updating user:', err.response.data.detail)
toast.error('出错了', {
description: '更新用户失败:' + err.response.data.detail,
duration: 4000,
closeButton: true
})
} finally {
loading.value = false
}
}
const handleResetPassword = (user: User) => {
selectedUser.value = user
showResetPasswordModal.value = true
}
const confirmResetPassword = async (newPassword: string) => {
if (!selectedUser.value) return
try {
resettingPassword.value = true
error.value = null
await resetUserPassword(selectedUser.value.id, newPassword)
showResetPasswordModal.value = false
selectedUser.value = null
toast.success('成功', {
description: '密码已重置',
duration: 4000,
closeButton: true
})
} catch (err: any) {
const detail = err.response?.data?.detail
// A rejected password comes back as FastAPI's validation-error array.
const description = Array.isArray(detail) ? detail[0]?.msg : detail
error.value = '重置密码失败'
// Never the raw axios error: its `config.data` carries the plaintext
// password, which would land in the browser console on a network failure.
console.error('Error resetting password:', description ?? err.message)
toast.error('出错了', {
description: '重置密码失败' + (description ? ':' + description : ''),
duration: 4000,
closeButton: true
})
} finally {
resettingPassword.value = false
}
}
const handleDeleteUser = async (user: User) => {
selectedUser.value = user
showDeleteModal.value = true
}
const confirmDeleteUser = async () => {
if (!selectedUser.value) return
try {
loading.value = true
error.value = null
await deleteUser(selectedUser.value.id)
// Remove user from local list
users.value = users.value.filter(u => u.id !== selectedUser.value?.id)
showDeleteModal.value = false
selectedUser.value = null
toast.success('成功', {
description: '用户已删除',
duration: 4000,
closeButton: true
})
} catch (err: any) {
error.value = '删除用户失败'
console.error('Error deleting user:', err.response.data.detail)
toast.error('出错了', {
description: '删除用户失败:' + err.response.data.detail,
duration: 4000,
closeButton: true
})
} finally {
loading.value = false
}
}
const handleCreateUser = async (userData: Partial<User> & ChatScopeFields & { password?: string }) => {
try {
loading.value = true
error.value = null
const newUser = await createUser(userData)
users.value.unshift(newUser) // Add to start of list
showCreateModal.value = false
toast.success('成功', {
description: '用户已创建',
duration: 4000,
closeButton: true
})
} catch (err: any) {
error.value = '创建用户失败'
console.error('Error creating user:', err.response.data.detail)
toast.error('出错了', {
description: '创建用户失败:' + err.response.data.detail,
duration: 4000,
closeButton: true
})
} finally {
loading.value = false
}
}
return {
users,
loading,
error,
showEditModal,
showDeleteModal,
selectedUser,
showCreateModal,
showResetPasswordModal,
resettingPassword,
fetchUsers,
handleEditUser,
handleUpdateUser,
handleDeleteUser,
confirmDeleteUser,
handleCreateUser,
handleResetPassword,
confirmResetPassword
}
}
@@ -0,0 +1,76 @@
/*
Copyright 2024-2026 ChatterMate
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 { onMounted, onUnmounted, ref } from 'vue'
/**
* Tracks the on-screen keyboard via the VisualViewport API and exposes the
* overlap as a --kb-offset CSS variable on <html>. Needed for installed iOS
* PWAs, where the keyboard overlays the layout viewport instead of resizing it.
*/
// Fire onChange only once the offset stops moving — keyboard show/hide is an
// animation emitting a burst of intermediate values, and reacting to each one
// (e.g. scroll-to-bottom) causes visible jank on mid-range phones.
const SETTLE_MS = 120
export function useVisualViewport(onChange?: (keyboardOffset: number) => void) {
const keyboardOffset = ref(0)
let rafId = 0
let settleTimer: ReturnType<typeof setTimeout> | undefined
const measure = () => {
rafId = 0
const vv = window.visualViewport
if (!vv) return
// Pinch-zoom also shrinks vv.height; that's not a keyboard. Only measure
// at (near) 1:1 scale so zooming doesn't produce a phantom offset.
const offset = vv.scale > 1.05
? 0
: Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
if (offset !== keyboardOffset.value) {
keyboardOffset.value = offset
document.documentElement.style.setProperty('--kb-offset', `${offset}px`)
clearTimeout(settleTimer)
settleTimer = setTimeout(() => onChange?.(offset), SETTLE_MS)
}
}
// Coalesce the event burst (visualViewport fires resize+scroll every frame
// during keyboard/URL-bar animation) into one measurement per frame
const update = () => {
if (!rafId) rafId = requestAnimationFrame(measure)
}
onMounted(() => {
const vv = window.visualViewport
if (!vv) return
vv.addEventListener('resize', update)
vv.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
const vv = window.visualViewport
if (!vv) return
vv.removeEventListener('resize', update)
vv.removeEventListener('scroll', update)
cancelAnimationFrame(rafId)
clearTimeout(settleTimer)
document.documentElement.style.setProperty('--kb-offset', '0px')
})
return { keyboardOffset }
}
+168
View File
@@ -0,0 +1,168 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
/**
* API Configuration - Uses runtime config from window.APP_CONFIG
* Functions are used instead of constants to ensure runtime config is read at call time
*/
import { buildUploadUrl } from '@/utils/avatars'
// API URLs - Dynamic functions..
export function getApiUrl(): string {
return (
window.APP_CONFIG?.API_URL || import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1'
)
}
export function getWsUrl(): string {
return window.APP_CONFIG?.WS_URL || import.meta.env.VITE_WS_URL || 'ws://localhost:8000'
}
/**
* An absolute API URL for `path`, for the places that need a real URL string
* rather than an axios call — browser navigations (OAuth installs), download
* links, webhook URLs we show the user.
*
* Use this instead of `${getApiUrl()}${path}` by hand. A self-hoster is free to
* set API_URL with a trailing slash, and hand-concatenation then emits
* `/api/v1//crm/...`; Starlette matches paths exactly, so that 404s.
*/
export function apiPath(path: string): string {
return `${getApiUrl().replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
}
/**
* URL for a stored upload path (avatar, attachment). Always use this instead of
* concatenating getApiUrl() by hand — stored local paths already include the
* /api/v1 prefix. See buildUploadUrl.
*/
export function resolveUploadUrl(stored?: string | null): string {
return buildUploadUrl(stored, getApiUrl())
}
/**
* The logged-in user's own avatar, signed when the browser asks for it.
*
* Not resolveUploadUrl(user.profile_pic): for S3 storage that value is a
* presigned URL, and the copy the dashboard holds comes from the `user_info`
* blob written at login and never rewritten. The signature dies an hour later
* and the avatar stays broken until the next login. This path re-signs per
* request, so it cannot go stale.
*
* Other people's avatars need no equivalent — those arrive on API responses,
* which sign afresh every time.
*/
export function myAvatarUrl(cacheBuster?: string | number): string {
const base = apiPath('/users/me/avatar')
return cacheBuster ? `${base}?t=${cacheBuster}` : base
}
export function getWidgetUrl(): string {
return window.APP_CONFIG?.WIDGET_URL || import.meta.env.VITE_WIDGET_URL || 'http://localhost:8000'
}
// Razorpay Checkout - Dynamic function
// Firebase Configuration - Dynamic functions
export function getFirebaseApiKey(): string {
return window.APP_CONFIG?.FIREBASE_API_KEY || import.meta.env.VITE_FIREBASE_API_KEY || ''
}
export function getFirebaseAuthDomain(): string {
return window.APP_CONFIG?.FIREBASE_AUTH_DOMAIN || import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || ''
}
export function getFirebaseProjectId(): string {
return window.APP_CONFIG?.FIREBASE_PROJECT_ID || import.meta.env.VITE_FIREBASE_PROJECT_ID || ''
}
export function getFirebaseMessagingSenderId(): string {
return (
window.APP_CONFIG?.FIREBASE_MESSAGING_SENDER_ID ||
import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID ||
''
)
}
export function getFirebaseAppId(): string {
return window.APP_CONFIG?.FIREBASE_APP_ID || import.meta.env.VITE_FIREBASE_APP_ID || ''
}
export function getFirebaseStorageBucket(): string {
return (
window.APP_CONFIG?.FIREBASE_STORAGE_BUCKET || import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || ''
)
}
export function getFirebaseMeasurementId(): string {
return (
window.APP_CONFIG?.FIREBASE_MEASUREMENT_ID || import.meta.env.VITE_FIREBASE_MEASUREMENT_ID || ''
)
}
export function getFirebaseVapidKey(): string {
return window.APP_CONFIG?.FIREBASE_VAPID_KEY || import.meta.env.VITE_FIREBASE_VAPID_KEY || ''
}
// Google Fonts API - Dynamic function
export function getGoogleFontsApiKey(): string {
return window.APP_CONFIG?.GOOGLE_FONTS_API_KEY || import.meta.env.VITE_GOOGLE_FONTS_API_KEY || ''
}
// Node Environment - Dynamic functions
export function getNodeEnv(): string {
return window.APP_CONFIG?.NODE_ENV || import.meta.env.NODE_ENV || 'development'
}
export function getHost(): string {
return window.APP_CONFIG?.HOST || import.meta.env.HOST || '0.0.0.0'
}
// Explore Configuration - Dynamic function
export function getDemoWidgetId(): string {
return (
(window.APP_CONFIG as any)?.DEMO_WIDGET_ID ||
import.meta.env.VITE_DEMO_WIDGET_ID ||
'397046dc-0093-4499-ab45-a0afe3c3ee14'
)
}
// Explore Configuration - Dynamic function
export function getSHOPIFY_API_KEY(): string {
return (
(window.APP_CONFIG as any)?.VITE_SHOPIFY_API_KEY ||
import.meta.env.VITE_SHOPIFY_API_KEY ||
'397046dc-0093-4499-ab45-a0afe3c3ee14'
)
}
// Legacy exports for backward compatibility - using getters to ensure dynamic evaluation
export const config = {
get API_URL() {
return getApiUrl()
},
get WS_URL() {
return getWsUrl()
},
get WIDGET_URL() {
return getWidgetUrl()
},
}
// Direct exports that work dynamically
export { getApiUrl as API_URL }
export { getWsUrl as WS_URL }
export { getWidgetUrl as WIDGET_URL }
+19
View File
@@ -0,0 +1,19 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
// Public product docs. One place, so a docs move is a one-line change.
export const DOCS_URL = 'https://docs.chattermate.chat'
export const WIDGET_INSTALL_GUIDE_URL = `${DOCS_URL}/features/widget`
+27
View File
@@ -0,0 +1,27 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
/**
* Browser/PWA chrome colors — MUST mirror --bg per theme in
* src/assets/styles/design-tokens.css (CSS variables can't be referenced from
* the manifest or <meta name="theme-color">). Consumed by useTheme.ts and the
* PWA manifest in vite.config.ts; index.html's static meta carries the dark
* value as the pre-JS default.
*/
export const THEME_COLORS = {
dark: '#0B0C10',
light: '#EEF0F4',
} as const
File diff suppressed because it is too large. Load diff
+64
View File
@@ -0,0 +1,64 @@
/*
Copyright 2024-2026 ChatterMate
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 './assets/styles/main.css'
import '@/assets/base.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import VueApexCharts from 'vue3-apexcharts'
import App from './App.vue'
import router from './router'
import { initializeFirebase } from './services/firebase'
import { setupPWA } from './pwa/register'
import 'floating-vue/dist/style.css'
import FloatingVue from 'floating-vue'
/* Font Awesome imports */
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { far } from '@fortawesome/free-regular-svg-icons'
import { fab } from '@fortawesome/free-brands-svg-icons'
/* Add icons to the library */
library.add(fas, far, fab)
const savedTheme = localStorage.getItem('cm-theme') ?? 'dark'
const resolvedTheme = savedTheme === 'system'
? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: savedTheme
document.documentElement.setAttribute('data-theme', resolvedTheme)
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.component('apexchart', VueApexCharts)
app.component('font-awesome-icon', FontAwesomeIcon)
app.use(FloatingVue)
initializeFirebase()
setupPWA()
// Initialize GTM only if enterprise module is available
// Cannot use useEnterpriseFeatures() here — runs before Vue app mounts
const enterpriseAnalytics = import.meta.glob('./modules/enterprise/utils/analytics.ts')
if (Object.keys(enterpriseAnalytics).length > 0) {
Object.values(enterpriseAnalytics)[0]().then((mod: any) => mod.initGTM?.())
}
app.mount('#app')
+378
View File
@@ -0,0 +1,378 @@
/*
Copyright 2024-2026 ChatterMate
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 { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import { userService } from '@/services/user'
import { getSetupStatus } from '@/services/organization'
import { canAccessMatchedPaths } from '@/router/routePermissions'
import { resolveLandingRoute } from '@/router/landing'
import { getApiUrl } from '@/config/api'
import HumanAgentView from '@/views/HumanAgentView.vue'
import OrganizationSettings from '@/views/settings/OrganizationSettings.vue'
import AIConfigSettings from '@/views/settings/AIConfigSettings.vue'
import IntegrationsSettings from '@/views/settings/IntegrationsSettings.vue'
import WidgetAppsSettings from '@/views/settings/WidgetAppsSettings.vue'
import UserSettingsView from '@/views/UserSettingsView.vue'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
// Initialize enterprise features
const { hasEnterpriseModule, loadModule, moduleImports, NotAvailableComponent } =
useEnterpriseFeatures()
// Base routes
const baseRoutes = [
{
path: '/',
// A function, not a string: permissions live in localStorage and are not
// readable when this module is first evaluated. Also the landing spot for
// an installed PWA (manifest start_url) and for pushes with no session.
//
// Only resolve with a session in hand. Resolving for a signed-out visitor
// yields the no-permission floor, which the guard then carries into
// ?redirect= and honours after login — landing an admin on User Settings.
redirect: () => (userService.isAuthenticated() ? resolveLandingRoute() : '/login'),
},
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { requiresAuth: false },
},
{
path: '/setup',
name: 'setup',
component: () => import('@/views/SetupView.vue'),
meta: { requiresAuth: false },
},
{
path: '/ai-agents',
name: 'ai-agents',
component: () => import('@/views/AIAgentView.vue'),
meta: { requiresAuth: true },
},
{
path: '/analytics',
name: 'analytics',
component: () => import('@/views/AnalyticsView.vue'),
meta: {
requiresAuth: true,
layout: 'dashboard',
title: '数据分析仪表盘',
},
},
{
path: '/knowledge',
name: 'knowledge',
component: () => import('@/views/KnowledgeView.vue'),
meta: {
requiresAuth: true,
layout: 'dashboard',
title: '知识库',
},
},
{
path: '/faq',
name: 'faq',
component: () => import('@/views/FaqView.vue'),
meta: {
requiresAuth: true,
layout: 'dashboard',
title: '常见问题与帮助中心',
},
},
{
path: '/tickets',
name: 'tickets',
component: () => import('@/views/TicketsView.vue'),
meta: {
requiresAuth: true,
layout: 'dashboard',
title: '工单',
},
},
{
path: '/tickets/:id',
name: 'ticket-detail',
component: () => import('@/views/TicketDetailView.vue'),
meta: {
requiresAuth: true,
layout: 'dashboard',
title: '工单详情',
},
},
{
path: '/settings/ticketing',
name: 'ticketing-settings',
component: () => import('@/views/settings/TicketingSettings.vue'),
meta: {
requiresAuth: true,
layout: 'dashboard',
title: '工单设置',
},
},
{
path: '/widget/:id',
name: 'widget',
component: () => import('@/webclient/WidgetBuilder.vue'),
meta: { requiresAuth: false },
},
{
path: '/conversations',
name: 'conversations',
component: () => import('../views/ConversationsView.vue'),
// Any inbox grant — own/group chats, the unclaimed queue, or everything
meta: {
requiresAuth: true,
},
},
{
path: '/people',
name: 'people',
// Without meta the page rendered and then 403'd on every request; the
// guard sends users who can't read the directory to /403 instead.
component: () => import('../views/PeopleView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/human-agents',
name: 'human-agents',
component: HumanAgentView,
meta: { requiresAuth: true },
},
{
path: '/settings/organization',
name: 'organization-settings',
component: OrganizationSettings,
meta: {
requiresAuth: true,
layout: 'dashboard',
},
},
{
path: '/settings/ai-config',
name: 'ai-config-settings',
component: AIConfigSettings,
meta: {
requiresAuth: true,
layout: 'dashboard',
},
},
{
path: '/settings/integrations',
name: 'integrations-settings',
component: IntegrationsSettings,
meta: {
requiresAuth: true,
layout: 'dashboard',
},
},
{
path: '/settings/widget-apps',
name: 'widget-apps',
component: WidgetAppsSettings,
meta: {
requiresAuth: true,
layout: 'dashboard',
},
},
{
path: '/settings/user',
name: 'user-settings',
component: UserSettingsView,
meta: {
requiresAuth: true,
layout: 'dashboard',
},
},
{
// Registered, and reachable: the guard used to send denials to /403 with
// no such route, so they fell through this catch-all onto /ai-agents —
// which had no meta.permissions and therefore always rendered. A user was
// silently dropped on a page they had just been refused.
path: '/403',
name: 'forbidden',
component: () => import('@/views/403.vue'),
meta: { requiresAuth: true, layout: 'dashboard' },
},
{
path: '/:pathMatch(.*)*',
redirect: () => (userService.isAuthenticated() ? resolveLandingRoute() : '/login'),
},
]
// Helper function to load enterprise component
const loadEnterpriseComponent = (path: string) => {
if (!hasEnterpriseModule) {
return NotAvailableComponent
}
return async () => {
const module = await loadModule(path)
return module?.default || NotAvailableComponent
}
}
// Combine routes based on module availability
const allRoutes = hasEnterpriseModule
? [
...baseRoutes,
{
path: '/signup',
name: 'signup',
component: loadEnterpriseComponent(moduleImports.signupView),
meta: { requiresAuth: false },
},
// Explore is retired and its backend proxy unregistered
// {
// path: '/explore',
// name: 'explore',
// component: loadEnterpriseComponent(moduleImports.exploreView),
// meta: { requiresAuth: false },
// },
{
path: '/settings/subscription',
name: 'subscription',
component: loadEnterpriseComponent(moduleImports.subscriptionView),
meta: {
requiresAuth: true,
layout: 'dashboard',
title: '订阅套餐',
},
},
{
path: '/settings/app-integrations',
name: 'app-integrations',
component: loadEnterpriseComponent(moduleImports.appIntegrationsView),
meta: {
requiresAuth: true,
layout: 'dashboard',
title: '应用集成',
},
},
{
path: '/settings/subscription/setup/:planId',
name: 'billing-setup',
component: loadEnterpriseComponent(moduleImports.billingSetupView),
meta: { requiresAuth: true },
},
{
path: '/shopify/session-token-bounce',
name: 'shopify-session-token-bounce',
component: loadEnterpriseComponent(moduleImports.shopifySessionTokenBounce),
meta: { requiresAuth: false },
},
{
path: '/shopify/connect',
name: 'shopify-connect',
component: loadEnterpriseComponent(moduleImports.shopifyConnect),
meta: { requiresAuth: false }, // Session token auth instead
},
{
path: '/shopify/auth-complete',
name: 'shopify-auth-complete',
component: loadEnterpriseComponent(moduleImports.shopifyAuthComplete),
meta: { requiresAuth: false },
},
{
path: '/shopify/agent-selection',
name: 'shopify-agent-selection',
component: loadEnterpriseComponent(moduleImports.shopifyAgentSelection),
meta: { requiresAuth: false }, // Session token auth instead
},
{
path: '/shopify/agent-management',
name: 'shopify-agent-management',
component: loadEnterpriseComponent(moduleImports.shopifyAgentManagement),
meta: { requiresAuth: false }, // Session token auth instead
},
{
path: '/shopify/inbox',
name: 'shopify-inbox',
component: loadEnterpriseComponent(moduleImports.shopifyInbox),
meta: { requiresAuth: false },
},
{
path: '/shopify/pricing',
name: 'shopify-pricing',
component: loadEnterpriseComponent(moduleImports.shopifyPricing),
meta: { requiresAuth: false },
},
]
: baseRoutes
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: allRoutes,
})
// Add subscription guard only if enterprise module is available
if (hasEnterpriseModule) {
const loadGuard = async () => {
try {
const guardModule = await loadModule(moduleImports.subscriptionGuard)
if (guardModule?.subscriptionGuard) {
router.beforeEach(guardModule.subscriptionGuard)
}
} catch (error) {
console.warn('Enterprise subscription guard not available:', error)
}
}
loadGuard()
}
// Navigation guard
router.beforeEach(async (to, from, next) => {
// Skip auth check for Shopify routes (they use session tokens)
if (to.path.startsWith('/shopify/')) {
return next()
}
// Check for standard app conditions
const isAuthenticated = userService.isAuthenticated()
// Always check setup status to decide between Setup vs Login/Signup
const isSetupComplete = await getSetupStatus()
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
// Standard app navigation logic
if (!isAuthenticated) {
// If setup is not complete, always go to setup page first
if (!isSetupComplete && to.path !== '/setup') {
return next('/setup')
} else if (requiresAuth) {
// Preserve where they were headed (e.g. the Slack install landing link)
// so login can send them straight back instead of the default page.
return next({ path: '/login', query: { redirect: to.fullPath } })
}
// Public route; allow
return next()
} else {
if (!isSetupComplete && to.path !== '/setup') {
return next('/setup')
}
}
// ROUTE_PERMISSIONS is the single source of truth, shared with the sidebar
// so a visible link can never lead to a refusal.
if (!canAccessMatchedPaths(to.matched.map((record) => record.path))) {
return next({ name: 'forbidden' })
}
return next()
})
export default router
+51
View File
@@ -0,0 +1,51 @@
/*
Copyright 2024-2026 ChatterMate
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 { canAccessPath } from '@/router/routePermissions'
/**
* Where to send someone who has not asked for a particular page: after login,
* from `/`, from the catch-all, and from the 403 page.
*
* Order matters — /ai-agents stays first so an admin's landing page is
* unchanged. The list ends at /settings/user, which requires nothing, so this
* always resolves to somewhere the user can actually open.
*/
const LANDING_CANDIDATES = [
'/ai-agents',
'/conversations',
'/tickets',
'/people',
'/human-agents',
'/analytics',
'/knowledge',
'/settings/organization',
'/settings/ai-config',
'/settings/user',
]
/**
* The first landing candidate this user may open.
*
* Never returns /403: bouncing someone to an error page as their *destination*
* is what made a permission denial look like a broken app.
*
* Call this at navigation time, never at module scope — permissions are read
* from localStorage, which is empty when the router module is first evaluated.
*/
export function resolveLandingRoute(): string {
return LANDING_CANDIDATES.find(canAccessPath) ?? '/settings/user'
}
+96
View File
@@ -0,0 +1,96 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
// Groups from the leaf module, not from permissions.ts: this map is built at
// module scope, and permissions.ts sits inside the router import cycle.
import {
AGENT_PERMISSIONS,
AI_CONFIG_PERMISSIONS,
CHAT_VIEW_PERMISSIONS,
KNOWLEDGE_PERMISSIONS,
ORGANIZATION_PERMISSIONS,
PEOPLE_PERMISSIONS,
TICKET_PERMISSIONS,
} from '@/utils/permissionGroups'
// Only ever called at runtime, so a partially-initialised module is harmless.
import { hasAnyPermission } from '@/utils/permissions'
/**
* The one place a route's required permissions are written down.
*
* Both the router guard and the sidebar (navItems `show`) read this. They used to name permissions independently and had drifted: People
* was listed in the nav for anyone with a chat grant but the route required
* view_people, so an assigned-chats-only agent saw a link that bounced them.
*
* An empty array means "any authenticated user".
*/
export const ROUTE_PERMISSIONS: Record<string, readonly string[]> = {
'/ai-agents': AGENT_PERMISSIONS,
'/analytics': ['view_analytics'],
'/knowledge': KNOWLEDGE_PERMISSIONS,
// manage_knowledge, not the read pair: every /help-center endpoint the page
// calls requires manage_knowledge, so a view_knowledge role would get the
// link and a page that 403s on its first request.
'/faq': ['manage_knowledge'],
'/tickets': TICKET_PERMISSIONS,
'/tickets/:id': TICKET_PERMISSIONS,
'/settings/ticketing': ['manage_organization'],
'/conversations': CHAT_VIEW_PERMISSIONS,
'/people': PEOPLE_PERMISSIONS,
'/human-agents': ['manage_users'],
'/settings/organization': ORGANIZATION_PERMISSIONS,
'/settings/ai-config': AI_CONFIG_PERMISSIONS,
'/settings/integrations': ['manage_organization'],
'/settings/app-integrations': ['manage_organization'],
'/settings/widget-apps': ['manage_organization'],
// Everyone can reach their own profile, and 403 must never deny itself or
// the guard has nowhere to send anyone.
'/settings/user': [],
'/403': [],
}
// Deliberately absent: /settings/subscription and its billing-setup child.
// The enterprise subscription guard redirects an expired org THERE, so gating
// it here would ping-pong (guard sends you to billing, this sends you to /403,
// guard sends you back) until Vue Router aborts. The nav hides it via
// canViewSubscription instead; gating the route needs the guard fixed first,
// which is an enterprise-submodule change.
/** Shopify embedded routes authenticate by session token, not by user role. */
const isShopifyPath = (path: string) => path.startsWith('/shopify')
/**
* Whether the current user may open `path`.
*
* Unknown paths are allowed: the map lists what is restricted, so a public or
* not-yet-mapped route must not become unreachable by omission.
*/
export function canAccessPath(path: string): boolean {
if (isShopifyPath(path)) return true
const required = ROUTE_PERMISSIONS[path]
if (!required || required.length === 0) return true
return hasAnyPermission([...required])
}
/**
* Whether the user may open a resolved route.
*
* Takes the matched record paths rather than the URL so a parameterised route
* is looked up by its pattern ('/tickets/:id'), not by '/tickets/42'.
*/
export function canAccessMatchedPaths(matchedPaths: string[]): boolean {
return matchedPaths.every(canAccessPath)
}
+166
View File
@@ -0,0 +1,166 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { AgentCustomization, Agent, AgentUpdate } from '@/types/agent'
import { agentStorage } from '@/utils/storage'
export const agentService = {
/**
* Names of the org's AI agents, for the inbox filter.
*
* Not getOrganizationAgents: that needs manage_agents and returns each
* agent's instructions and guardrail prompt. Filtering needs a name.
*/
async getAgentRoster(): Promise<Array<{ id: string; name: string; display_name: string | null }>> {
const response = await api.get('/agent/roster')
return response.data
},
async getOrganizationAgents(): Promise<Agent[]> {
// Check if we're in a Shopify context (has shop query param or session token)
const urlParams = new URLSearchParams(window.location.search)
const hasShopParam = urlParams.has('shop') || urlParams.has('host')
const endpoint = hasShopParam ? '/agent/list/shopify' : '/agent/list'
const response = await api.get(endpoint)
// Store agents in local storage
agentStorage.setAgents(response.data)
return response.data
},
async createAgent(data: {
name: string;
display_name: string;
agent_type: string;
instructions: string[];
is_active: boolean;
use_workflow?: boolean;
}): Promise<Agent> {
const response = await api.post('/agent', data)
// Update agent in local storage
const agents = agentStorage.getAgents()
agents.push(response.data)
agentStorage.setAgents(agents)
return response.data
},
async updateAgent(
agentId: string,
data: AgentUpdate
): Promise<Agent> {
// Check if we're in a Shopify context
const urlParams = new URLSearchParams(window.location.search)
const hasShopParam = urlParams.has('shop') || urlParams.has('host')
const endpoint = hasShopParam ? `/agent/${agentId}/shopify` : `/agent/${agentId}`
const response = await api.put(endpoint, data)
// Update agent in local storage
agentStorage.updateAgent(response.data)
return response.data
},
async updateCustomization(
agentId: string,
customization: AgentCustomization,
): Promise<AgentCustomization> {
// Check if we're in a Shopify context
const urlParams = new URLSearchParams(window.location.search)
const hasShopParam = urlParams.has('shop') || urlParams.has('host')
const endpoint = hasShopParam ? `/agent/${agentId}/customization/shopify` : `/agent/${agentId}/customization`
const response = await api.post(endpoint, customization)
// Update agent customization in local storage
const agents = agentStorage.getAgents()
const agent = agents.find((a) => a.id === agentId)
if (agent) {
agent.customization = response.data
agentStorage.updateAgent(agent)
// Update active agent if it's the same one
const activeAgent = agentStorage.getActiveAgent()
if (activeAgent && activeAgent.id === agentId) {
agentStorage.setActiveAgent(agent)
}
}
return response.data
},
async uploadAgentPhoto(agentId: string, file: File): Promise<AgentCustomization> {
const formData = new FormData()
formData.append('photo', file)
// Check if we're in a Shopify context
const urlParams = new URLSearchParams(window.location.search)
const hasShopParam = urlParams.has('shop') || urlParams.has('host')
const endpoint = hasShopParam ? `/agent/${agentId}/customization/photo/shopify` : `/agent/${agentId}/customization/photo`
const response = await api.post(endpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
// Update agent customization in local storage
const agents = agentStorage.getAgents()
const agent = agents.find((a) => a.id === agentId)
if (agent) {
agent.customization = response.data
agentStorage.updateAgent(agent)
// Update active agent if it's the same one
const activeAgent = agentStorage.getActiveAgent()
if (activeAgent && activeAgent.id === agentId) {
agentStorage.setActiveAgent(agent)
}
}
return response.data
},
async updateAgentGroups(agentId: string, groupIds: string[]): Promise<Agent> {
const response = await api.put(`/agent/${agentId}/groups`, groupIds)
return response.data
},
async getAgentById(agentId: string): Promise<Agent> {
// Check if we're in a Shopify context
const urlParams = new URLSearchParams(window.location.search)
const hasShopParam = urlParams.has('shop') || urlParams.has('host')
const endpoint = hasShopParam ? `/agent/${agentId}/shopify` : `/agent/${agentId}`
const response = await api.get(endpoint)
return response.data
},
/** The shipped guardrail rule with {org} filled in, shown as editable starting text. */
async getGuardrailDefault(): Promise<string> {
const response = await api.get('/agent/guardrail-default')
return response.data?.prompt ?? ''
},
async generateInstructions(prompt: string, existingInstructions?: string[]): Promise<string[]> {
const response = await api.post('/agent/generate-instructions', {
prompt,
existing_instructions: existingInstructions
})
return response.data
}
}
+46
View File
@@ -0,0 +1,46 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { AIConfigResponse, AISetupResponse, AIProvider } from '@/types/ai'
export interface AIConfig {
model_type: string;
model_name: string;
api_key: string;
base_url?: string;
settings?: Record<string, unknown>;
}
export const aiService = {
async getProviders(): Promise<AIProvider[]> {
const response = await api.get('/ai/providers')
return response.data.providers
},
async getOrganizationConfig(): Promise<AIConfig> {
const response = await api.get('/ai/config')
return response.data
},
async setupAI(config: AIConfig): Promise<void> {
await api.post('/ai/setup', config)
},
async updateAI(config: AIConfig): Promise<void> {
await api.put('/ai/config', config)
},
}
+173
View File
@@ -0,0 +1,173 @@
/*
Copyright 2024-2026 ChatterMate
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 axios, { AxiosError } from 'axios'
import router from '@/router'
import { getApiUrl } from '@/config/api'
import { userService } from '@/services/user'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
const { hasEnterpriseModule, loadModule, moduleImports } = useEnterpriseFeatures()
// Lazy load Shopify dependencies only if enterprise module is available
let getSessionToken: any = null
let initShopifyApp: any = null
if (hasEnterpriseModule) {
// Load Shopify dependencies asynchronously
;(async () => {
try {
// Load Shopify App Bridge utilities via wrapper module
const appBridgeUtilitiesModule = await loadModule(moduleImports.shopifyAppBridgeUtilities)
if (appBridgeUtilitiesModule?.getSessionToken) {
getSessionToken = appBridgeUtilitiesModule.getSessionToken
}
// Load Shopify app bridge plugin
const shopifyAppBridgeModule = await loadModule(moduleImports.shopifyAppBridge)
if (shopifyAppBridgeModule?.initShopifyApp) {
initShopifyApp = shopifyAppBridgeModule.initShopifyApp
}
} catch (error) {
console.warn('Failed to load Shopify dependencies:', error)
}
})()
}
const api = axios.create({
baseURL: getApiUrl(),
withCredentials: true, // Important for cookies
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add session token for Shopify embedded app
api.interceptors.request.use(
async (config) => {
// Check if we're in embedded Shopify context
const urlParams = new URLSearchParams(window.location.search)
const hasShopParam = urlParams.has('shop') || urlParams.has('host')
// Check if this is a Shopify-related endpoint
const isShopifyEndpoint =
config.url?.includes('/shopify/') ||
config.url?.endsWith('/shopify') ||
(config.url?.includes('/agent/') && hasShopParam) ||
(config.url?.includes('/chats/') && hasShopParam)
if (
hasShopParam &&
isShopifyEndpoint &&
hasEnterpriseModule &&
initShopifyApp &&
getSessionToken
) {
try {
const app = initShopifyApp()
if (app) {
const token = await getSessionToken(app)
if (token) {
config.headers.Authorization = `Bearer ${token}`
console.log('✅ Added Shopify session token to request:', config.url)
}
}
} catch (error) {
console.error('❌ Failed to add session token:', error)
}
}
return config
},
(error) => Promise.reject(error),
)
/** Signs the user out locally and sends them to the login page. */
function signOut() {
document.cookie = 'user_info=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
// Remove any cached auth state so guards don't think we're logged in
try {
userService.clearCurrentUser()
} catch {}
router.push('/login')
}
/**
* The refresh in flight, shared. A page opening with an expired access token
* gets a 401 on every call it makes at once; each used to refresh on its own,
* and the burst tripped the API rate limit, so one refresh got a 503 and the
* user was signed out.
*/
let refreshing: Promise<unknown> | null = null
function refreshSession(): Promise<unknown> {
refreshing ??= axios
.post('/users/refresh', {}, { withCredentials: true, baseURL: getApiUrl() })
.finally(() => {
refreshing = null
})
return refreshing
}
/**
* Only the server saying the session is over (401/403 from the refresh) ends
* it. A 502 while the API restarts on a deploy, a 503 from rate limiting or a
* dropped connection says nothing about the session: the request fails, the
* user stays signed in, and the next one retries.
*/
function sessionIsOver(refreshError: unknown): boolean {
const status = (refreshError as AxiosError)?.response?.status
return status === 401 || status === 403
}
// Response interceptor
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config
// If error is 401 and we haven't tried to refresh token yet
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
// If there's no authenticated user, do not attempt refresh. Go to login directly.
if (!userService.isAuthenticated()) {
signOut()
return Promise.reject(error)
}
originalRequest._retry = true
try {
await refreshSession()
} catch (refreshError) {
if (sessionIsOver(refreshError)) signOut()
return Promise.reject(refreshError)
}
// Retry original request
return api(originalRequest)
}
return Promise.reject(error)
},
)
export default api
// Add type for extended axios config
declare module 'axios' {
export interface AxiosRequestConfig {
_retry?: boolean
}
}
+77
View File
@@ -0,0 +1,77 @@
/*
Copyright 2024-2026 ChatterMate
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 type { AxiosError } from 'axios'
import api from './api'
import { userService } from './user'
import type { User } from '../types/user'
interface ErrorResponse {
detail: string
}
interface LoginResponse {
access_token: string
refresh_token: string
token_type: string
user: User
}
export const authService = {
async login(email: string, password: string) {
try {
const formData = new URLSearchParams()
formData.append('username', email)
formData.append('password', password)
// Get shop_id from URL query params if it exists
const urlParams = new URLSearchParams(window.location.search)
const shopId = urlParams.get('shop_id')
// Build the URL with query params if shop_id exists
let url = '/users/login'
if (shopId) {
url += `?shop_id=${encodeURIComponent(shopId)}`
}
const response = await api.post<LoginResponse>(url, formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
userService.setCurrentUser(response.data.user)
return response.data.user
} catch (err) {
const axiosError = err as AxiosError<ErrorResponse>
throw new Error(axiosError.response?.data?.detail || 'Login failed')
}
},
async logout(): Promise<void> {
try {
await api.post('/users/logout')
userService.clearCurrentUser()
localStorage.removeItem('agents')
localStorage.removeItem('current_subscription')
localStorage.removeItem('exploreView_customization')
localStorage.removeItem('exploreView_selectedColor')
localStorage.removeItem('exploreView_url')
} catch (err) {
const axiosError = err as AxiosError<ErrorResponse>
throw new Error(axiosError.response?.data?.detail || 'Logout failed')
}
},
}
+358
View File
@@ -0,0 +1,358 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import { apiPath } from '@/config/api'
import type { SignupChannel } from '@/utils/metaSdk'
// Types
export type ChannelType =
| 'web'
| 'telegram'
| 'whatsapp'
| 'messenger'
| 'instagram'
| 'slack'
| 'email'
| 'sms'
| 'line'
| 'api'
export interface SmsProviderField {
key: string
label: string
secret: boolean
optional: boolean
}
export interface SmsProviderInfo {
name: string
label: string
fields: SmsProviderField[]
}
/**
* Meta reviews every template; only APPROVED ones can be sent. The backend
* passes Graph's value through verbatim and Meta adds statuses over time
* (PENDING_DELETION, IN_APPEAL, FLAGGED, ...), so this list is the common set,
* not an exhaustive one — treat any unlisted status as "cannot send".
*/
export type TemplateStatus =
| 'APPROVED'
| 'PENDING'
| 'REJECTED'
| 'PAUSED'
| 'DISABLED'
| (string & {})
export type TemplateCategory = 'MARKETING' | 'UTILITY' | 'AUTHENTICATION'
/** One piece of a template — the BODY carries the text and its {{n}} variables. */
export interface TemplateComponent {
type: string
text?: string
format?: string
[key: string]: unknown
}
export interface WhatsAppTemplate {
id?: string
name: string
status?: TemplateStatus
category?: TemplateCategory
language?: string
components?: TemplateComponent[]
}
/**
* Whether the WhatsApp connect UI can offer Embedded Signup. Everything but
* `enabled` is null when it can't — a self-hoster has no ChatterMate Meta app
* to onboard under, and the plan check stays on the server.
*/
export interface EmbeddedSignupConfig {
enabled: boolean
config_id: string | null
app_id: string | null
graph_version: string
}
/** A Facebook Page offered in the Messenger signup picker (no token — that
* stays sealed in signup_token on the server). */
export interface MessengerSignupPage {
id: string
name: string
}
export interface MessengerSignupPages {
pages: MessengerSignupPage[]
/** Opaque blob carrying the Pages' access tokens back to the connect step */
signup_token: string
}
export interface ChannelAccount {
id: string
channel_type: ChannelType
external_account_id: string
display_name?: string
is_active: boolean
agent_id?: string
created_at?: string
/** For email/SMS/LINE and Meta accounts from the customer's own app: the
* webhook URL to configure on the provider */
webhook_url?: string
/** The platform refused this account's credentials; replies fail until it is reconnected */
needs_reconnect?: boolean
/** Meta accounts from the customer's own app: the verify token Meta asks for */
webhook_verify_token?: string
/** WhatsApp from the customer's own app: whether Meta took our callback */
webhook_auto_configured?: boolean
}
const channelsService = {
/** All connected messaging channel accounts for the organization */
async listAccounts(): Promise<ChannelAccount[]> {
const response = await api.get('/channels/accounts')
return response.data
},
/**
* The WhatsApp numbers an outbound conversation can be sent FROM.
*
* Both entry points want this exact question answered, and each was asking
* it by fetching the whole account list and applying the same filter. It
* resolves to [] rather than throwing: a surface that cannot list accounts
* should hide its entry point, not break the page around it.
*/
async listActiveWhatsAppAccounts(): Promise<ChannelAccount[]> {
try {
const accounts = await this.listAccounts()
return accounts.filter(
(account) => account.channel_type === 'whatsapp' && account.is_active,
)
} catch {
return []
}
},
/** Connect a Telegram bot by token; backend validates and registers the webhook */
async connectTelegram(botToken: string): Promise<ChannelAccount> {
const response = await api.post('/channels/telegram', { bot_token: botToken })
return response.data
},
async disconnectTelegram(accountId: string): Promise<void> {
await api.delete(`/channels/telegram/${accountId}`)
},
/** Connect a WhatsApp Cloud API number (manual credentials from a Meta app) */
async connectWhatsApp(payload: {
phone_number_id: string
access_token: string
waba_id?: string
app_secret?: string
}): Promise<ChannelAccount> {
const response = await api.post('/channels/meta/whatsapp', payload)
return response.data
},
/** Whether to offer a Meta login flow for this channel, and the ids the SDK needs */
async getEmbeddedSignupConfig(channel: SignupChannel = 'whatsapp'): Promise<EmbeddedSignupConfig> {
const response = await api.get('/channels/meta/embedded-signup-config', { params: { channel } })
return response.data
},
/** Finish an Embedded Signup: the backend trades the code for the token */
async connectWhatsAppEmbeddedSignup(payload: {
code: string
waba_id: string
phone_number_id: string
}): Promise<ChannelAccount> {
const response = await api.post('/channels/meta/whatsapp/embedded-signup', payload)
return response.data
},
/** Connect a Facebook Page for Messenger (manual page token) */
async connectMessenger(payload: {
page_id: string
page_access_token: string
app_secret?: string
}): Promise<ChannelAccount> {
const response = await api.post('/channels/meta/messenger', payload)
return response.data
},
/** Step 1 of Facebook Login for Business: trade the code for the Pages the
* customer can connect. Their tokens stay sealed in signup_token. */
async listMessengerSignupPages(code: string, redirectUri: string): Promise<MessengerSignupPages> {
const response = await api.post('/channels/meta/messenger/signup/pages', {
code,
redirect_uri: redirectUri,
})
return response.data
},
/** Step 2: connect the picked Page; the backend uses the token it sealed, not ours */
async connectMessengerSignup(payload: { signup_token: string; page_id: string }): Promise<ChannelAccount> {
const response = await api.post('/channels/meta/messenger/signup/connect', payload)
return response.data
},
/** Connect an Instagram account via Instagram Login. One account per login,
* so unlike Messenger this is a single step with nothing to pick. */
async connectInstagramLogin(code: string, redirectUri: string): Promise<ChannelAccount> {
const response = await api.post('/channels/meta/instagram/login/connect', {
code,
redirect_uri: redirectUri,
})
return response.data
},
/** Connect an Instagram professional account (via its linked page token) */
async connectInstagram(payload: { ig_id: string; page_access_token: string }): Promise<ChannelAccount> {
const response = await api.post('/channels/meta/instagram', payload)
return response.data
},
/** Disconnect any Meta channel account (WhatsApp/Messenger/Instagram) */
async disconnectMeta(accountId: string): Promise<void> {
await api.delete(`/channels/meta/${accountId}`)
},
/** Templates on a WhatsApp number's Business Account, in every status */
async listWhatsAppTemplates(accountId: string): Promise<WhatsAppTemplate[]> {
const response = await api.get(`/channels/meta/whatsapp/${accountId}/templates`)
return response.data
},
/**
* Where to write a template: Meta's Template Library, deep-linked to this
* number's Business Account. Templates are authored there, not here — Meta's
* library holds ~150 pre-written, pre-localised templates shaped to pass its
* own review, which a form of ours could only ever approximate badly.
*/
async getWhatsAppTemplateLibraryUrl(accountId: string): Promise<string> {
const response = await api.get(`/channels/meta/whatsapp/${accountId}/template-library`)
return response.data.url
},
/**
* Start a WhatsApp conversation with a phone number via an approved
* Utility/Authentication template. Returns the session to open in the
* inbox; a reply lands there and the AI answers with the template as
* context. customer_id links to a person picked from People.
*/
async startWhatsAppConversation(
accountId: string,
payload: {
to: string
template_name: string
language?: string
components?: TemplateComponent[]
customer_id?: string
customer_name?: string
},
): Promise<{ session_id: string }> {
const response = await api.post(`/channels/meta/whatsapp/${accountId}/conversations`, payload)
return response.data
},
/** Send an approved template to reopen a conversation whose 24h window closed */
async sendWhatsAppTemplate(
accountId: string,
payload: {
session_id: string
template_name: string
language?: string
components?: TemplateComponent[]
},
): Promise<{ status: string; external_message_id?: string }> {
const response = await api.post(`/channels/meta/whatsapp/${accountId}/send-template`, payload)
return response.data
},
/** Connect a support inbox. Optional SMTP fields send replies from the
* inbox's own domain; omit them to use the platform mail server. */
async connectEmail(payload: {
inbound_address: string
display_name?: string
smtp_host?: string
smtp_port?: number
smtp_username?: string
smtp_password?: string
from_email?: string
}): Promise<ChannelAccount> {
const response = await api.post('/channels/email', payload)
return response.data
},
async disconnectEmail(accountId: string): Promise<void> {
await api.delete(`/channels/email/${accountId}`)
},
/** Available SMS providers + the credential fields each needs */
async listSmsProviders(): Promise<SmsProviderInfo[]> {
const response = await api.get('/channels/sms/providers')
return response.data
},
/** Connect an SMS number through a chosen provider */
async connectSms(payload: {
provider: string
phone_number: string
credentials: Record<string, string>
}): Promise<ChannelAccount> {
const response = await api.post('/channels/sms', payload)
return response.data
},
async disconnectSms(accountId: string): Promise<void> {
await api.delete(`/channels/sms/${accountId}`)
},
/** Connect a LINE Official Account */
async connectLine(payload: { channel_secret: string; channel_access_token: string }): Promise<ChannelAccount> {
const response = await api.post('/channels/line', payload)
return response.data
},
async disconnectLine(accountId: string): Promise<void> {
await api.delete(`/channels/line/${accountId}`)
},
/** Browser URL that starts the Slack OAuth install (redirects to Slack) */
getSlackInstallUrl(): string {
return apiPath('/channels/slack/install')
},
async disconnectSlack(accountId: string): Promise<void> {
await api.delete(`/channels/slack/${accountId}`)
},
/** Route a connected account to an AI agent */
async setAccountAgent(accountId: string, agentId: string, isActive = true): Promise<ChannelAccount> {
const response = await api.post(`/channels/agent-config/${accountId}`, {
agent_id: agentId,
is_active: isActive,
})
return response.data
},
async clearAccountAgent(accountId: string): Promise<void> {
await api.delete(`/channels/agent-config/${accountId}`)
},
}
export default channelsService
+67
View File
@@ -0,0 +1,67 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { Conversation, ChatDetail } from '@/types/chat'
interface ChatParams {
skip?: number
limit?: number
agent_id?: string
status?: 'open' | 'closed' | 'transferred' | string
user_id?: string
customer_email?: string
date_from?: string
date_to?: string
}
export const chatService = {
async getRecentChats(params?: ChatParams) {
// Check if we're in a Shopify context
const urlParams = new URLSearchParams(window.location.search)
const hasShopParam = urlParams.has('shop') || urlParams.has('host')
const endpoint = hasShopParam ? '/chats/recent/shopify' : '/chats/recent'
const response = await api.get(endpoint, { params })
return response.data as Conversation[]
},
async getChatDetail(sessionId: string) {
// Check if we're in a Shopify context
const urlParams = new URLSearchParams(window.location.search)
const hasShopParam = urlParams.has('shop') || urlParams.has('host')
const endpoint = hasShopParam ? `/chats/${sessionId}/shopify` : `/chats/${sessionId}`
const response = await api.get<ChatDetail>(endpoint)
return response.data
},
async takeoverChat(sessionId: string): Promise<void> {
const response = await api.post(`/sessions/${sessionId}/takeover`)
return response.data
},
/** Stop the AI on this chat and queue it for the team, without claiming it. */
async routeToHuman(sessionId: string): Promise<ChatDetail> {
const response = await api.post(`/sessions/${sessionId}/route-to-human`)
return response.data as ChatDetail
},
async reassignChat(sessionId: string, toUserId: string) {
const response = await api.post(`/sessions/${sessionId}/reassign`, null, { params: { to_user_id: toUserId } })
return response.data as ChatDetail
}
}
+62
View File
@@ -0,0 +1,62 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import { apiPath } from '@/config/api'
export type CrmProvider = 'hubspot' | 'pipedrive'
export interface CrmConnection {
provider: CrmProvider
status: 'active' | 'expired' | 'revoked'
display_name: string | null
external_account_id: string
last_error: string | null
created_at: string | null
recent_failures: number
}
export interface CrmTestResult {
ok: boolean
account_name: string | null
error: string | null
}
export default {
async listConnections(): Promise<CrmConnection[]> {
const response = await api.get('/crm/connections')
return response.data
},
/** Browser-navigation URL — the install endpoint replies with a redirect
* to the CRM's OAuth consent page.
*
* Resolved at call time, like every other API URL: import.meta.env is baked
* at build time, so a self-hoster's window.APP_CONFIG.API_URL never reaches
* it and the browser leaves for the wrong host with no session cookie. */
getInstallUrl(provider: CrmProvider): string {
return apiPath(`/crm/${provider}/install`)
},
async testConnection(provider: CrmProvider): Promise<CrmTestResult> {
const response = await api.post(`/crm/${provider}/test`)
return response.data
},
async disconnect(provider: CrmProvider): Promise<void> {
await api.delete(`/crm/${provider}`)
},
}
+291
View File
@@ -0,0 +1,291 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type {
FaqGenerationJob,
FaqItem,
FaqImportMode,
FaqListResponse,
FaqSeoFields,
FaqStatus,
GenerateEstimate,
HelpCenterDomain,
HelpCenterSettings,
HelpCenterSettingsUpdate,
} from '@/types/faq'
function errorMessage(error: any, fallback: string): Error {
return new Error(error.response?.data?.detail || fallback)
}
// Backend bulk endpoints cap faq_ids at 200 (MAX_BULK_IDS in schemas/faq.py);
// split larger selections so a select-all over a big FAQ set still works.
const MAX_BULK_IDS = 200
function chunk<T>(items: T[], size: number): T[][] {
const batches: T[][] = []
for (let i = 0; i < items.length; i += size) batches.push(items.slice(i, i + size))
return batches
}
export const faqService = {
async getFaqs(params: { status?: FaqStatus; category?: string; q?: string; page?: number; page_size?: number } = {}): Promise<FaqListResponse> {
try {
const response = await api.get('/help-center/faqs', { params: { page_size: 200, ...params } })
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load FAQs')
}
},
async uploadImage(file: File): Promise<string> {
try {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/help-center/faqs/image', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data.url as string
} catch (error: any) {
throw errorMessage(error, 'Failed to upload image')
}
},
async createFaq(
payload: {
question: string
answer: string
category?: string
status?: FaqStatus
} & Partial<FaqSeoFields>,
): Promise<FaqItem> {
try {
const response = await api.post('/help-center/faqs', payload)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to create FAQ')
}
},
async updateFaq(
id: string,
payload: Partial<
Pick<FaqItem, 'question' | 'answer' | 'category' | 'status'> & FaqSeoFields
>,
): Promise<FaqItem> {
try {
const response = await api.put(`/help-center/faqs/${id}`, payload)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to update FAQ')
}
},
async deleteFaq(id: string): Promise<void> {
try {
await api.delete(`/help-center/faqs/${id}`)
} catch (error: any) {
throw errorMessage(error, 'Failed to delete FAQ')
}
},
async setStatus(ids: string[], status: FaqStatus): Promise<number> {
try {
let updated = 0
for (const batch of chunk(ids, MAX_BULK_IDS)) {
const response = await api.post('/help-center/faqs/bulk-status', { faq_ids: batch, status })
updated += response.data.updated
}
return updated
} catch (error: any) {
throw errorMessage(error, 'Failed to update FAQ status')
}
},
async bulkDelete(ids: string[]): Promise<number> {
try {
let deleted = 0
for (const batch of chunk(ids, MAX_BULK_IDS)) {
const response = await api.post('/help-center/faqs/bulk-delete', { faq_ids: batch })
deleted += response.data.deleted
}
return deleted
} catch (error: any) {
throw errorMessage(error, 'Failed to delete FAQs')
}
},
async getGenerateEstimate(includePages = true): Promise<GenerateEstimate> {
try {
const response = await api.get('/help-center/generate/estimate', {
params: { include_pages: includePages },
})
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load generation estimate')
}
},
async startGeneration(knowledgeIds?: number[]): Promise<FaqGenerationJob> {
try {
const response = await api.post(
'/help-center/generate',
knowledgeIds?.length ? { knowledge_ids: knowledgeIds } : {},
)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to start generation')
}
},
async importFaq(
url: string,
mode: FaqImportMode = 'qa',
preserveUrls = false,
): Promise<FaqGenerationJob> {
try {
const response = await api.post('/help-center/import', {
url,
mode,
preserve_urls: preserveUrls,
})
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to start import')
}
},
async importPdf(file: File): Promise<FaqGenerationJob> {
try {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/help-center/import/pdf', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to start PDF import')
}
},
async getJob(active = true): Promise<FaqGenerationJob | null> {
try {
const response = await api.get('/help-center/jobs', { params: { active } })
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load job status')
}
},
async getSettings(): Promise<HelpCenterSettings> {
try {
const response = await api.get('/help-center/settings')
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load help center settings')
}
},
async updateSettings(payload: HelpCenterSettingsUpdate): Promise<HelpCenterSettings> {
try {
const response = await api.put('/help-center/settings', payload)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to save help center settings')
}
},
async uploadLogo(file: File): Promise<HelpCenterSettings> {
try {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/help-center/logo', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to upload logo')
}
},
async removeLogo(): Promise<HelpCenterSettings> {
try {
const response = await api.delete('/help-center/logo')
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to remove logo')
}
},
async uploadFavicon(file: File): Promise<HelpCenterSettings> {
try {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/help-center/favicon', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to upload favicon')
}
},
async removeFavicon(): Promise<HelpCenterSettings> {
try {
const response = await api.delete('/help-center/favicon')
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to remove favicon')
}
},
async setDomain(domain: string): Promise<HelpCenterDomain> {
try {
const response = await api.post('/help-center/domain', { domain })
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to set custom domain')
}
},
async removeDomain(): Promise<HelpCenterDomain> {
try {
const response = await api.delete('/help-center/domain')
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to remove custom domain')
}
},
async verifyDomain(): Promise<HelpCenterDomain> {
try {
const response = await api.post('/help-center/domain/verify')
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to verify domain')
}
},
async getDomainStatus(): Promise<HelpCenterDomain> {
try {
const response = await api.get('/help-center/domain/status')
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load domain status')
}
},
}
+185
View File
@@ -0,0 +1,185 @@
/*
Copyright 2024-2026 ChatterMate
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 { initializeApp, getApps, type FirebaseApp } from 'firebase/app'
import { isSupported, type Messaging } from 'firebase/messaging'
import { userService } from './user'
import { getSWRegistration } from '@/pwa/register'
import {
getFirebaseApiKey,
getFirebaseAuthDomain,
getFirebaseProjectId,
getFirebaseMessagingSenderId,
getFirebaseAppId,
getFirebaseStorageBucket,
getFirebaseMeasurementId,
getFirebaseVapidKey,
} from '@/config/api'
// Runtime config (window.APP_CONFIG, docker-substituted) with build-time fallback
const firebaseConfig = () => ({
apiKey: getFirebaseApiKey(),
authDomain: getFirebaseAuthDomain(),
projectId: getFirebaseProjectId(),
messagingSenderId: getFirebaseMessagingSenderId(),
appId: getFirebaseAppId(),
storageBucket: getFirebaseStorageBucket(),
measurementId: getFirebaseMeasurementId(),
})
let app: FirebaseApp | null = null
export const initializeFirebase = () => {
if (!getApps().length) {
app = initializeApp(firebaseConfig())
}
return app
}
/**
* Lazy, guarded messaging init. Never touch getMessaging() at module scope:
* on iOS Safari (not installed to Home Screen) the Push API doesn't exist and
* getMessaging() throws, taking the whole app bundle down with it.
*/
// Memoized: firebase's isSupported() probes IndexedDB on every call
let pushSupported: Promise<boolean> | null = null
export const isPushSupported = () =>
(pushSupported ??= isSupported().catch(() => false))
export const getMessagingIfSupported = async (): Promise<Messaging | null> => {
if (!(await isPushSupported())) return null
const { getMessaging } = await import('firebase/messaging')
return getMessaging(initializeFirebase() ?? undefined)
}
// The token for this browser install, or null if push can't run here (no
// support, no service worker, permission not granted).
const getCurrentToken = async (): Promise<string | null> => {
const messaging = await getMessagingIfSupported()
if (!messaging) return null
// Reuse the app service worker so Firebase never registers a second one;
// without a registration (dev, failed install) there is nothing to bind to.
const serviceWorkerRegistration = await getSWRegistration()
if (!serviceWorkerRegistration) return null
const { getToken } = await import('firebase/messaging')
return await getToken(messaging, {
vapidKey: getFirebaseVapidKey(),
serviceWorkerRegistration,
})
}
// Mint an FCM token against the app service worker and store it server-side.
// Requires notification permission to already be granted.
//
// This posts on every call rather than caching "already synced" in
// localStorage. FCM returns the same token for the life of a browser install,
// so a cache keyed on it never invalidates — and the server value can change
// without the client knowing (a logout elsewhere used to clear it, and dead
// tokens get pruned server-side). A stale cache meant the device believed it
// was registered while the server had nothing, and push stayed dead until site
// data was cleared. One small POST per app load is the cheaper failure mode.
const registerFCMToken = async (): Promise<boolean> => {
const token = await getCurrentToken()
if (!token) return false
await userService.updateFCMToken(token)
return true
}
/**
* Ask for notification permission and register the FCM token.
* Call from a user gesture (button tap) — on installed iOS PWAs a request
* outside a gesture silently fails.
*/
export const requestNotificationPermission = async (): Promise<boolean> => {
try {
if (!('Notification' in window)) {
console.error("Browser doesn't support notifications")
return false
}
if (Notification.permission === 'denied') return false
const permission = await Notification.requestPermission()
if (permission !== 'granted') return false
return await registerFCMToken()
} catch (err) {
console.error('Failed to get notification permission:', err)
return false
}
}
/**
* Re-mint the FCM token for users whose permission is already granted. Needed
* after the migration off firebase-messaging-sw.js: the old worker (and the
* push subscription the stored token was bound to) is unregistered on upgrade,
* so without this existing users would silently stop receiving pushes. Also
* keeps tokens fresh across normal FCM rotation.
*/
export const refreshFCMToken = async (): Promise<void> => {
try {
if (!('Notification' in window) || Notification.permission !== 'granted') return
await registerFCMToken()
} catch (err) {
console.error('Failed to refresh FCM token:', err)
}
}
/**
* Drop this browser's token on logout — server-side and at FCM.
*
* Only this device is unregistered; other devices the user is signed in on
* keep working. The two steps are independent on purpose: if the token can't
* be read (service worker not ready), the server row can't be named, but
* deleteToken() must still run — otherwise a shared computer keeps rendering
* the signed-out account's notifications. Killing the subscription at FCM
* makes the next send fail as Unregistered, which prunes the row server-side.
*
* Bounded by UNREGISTER_TIMEOUT_MS so signing out is never held up by it: it
* waits on getSWRegistration(), which only settles after an 8s timeout when no
* worker is ready. Giving up early is safe — a browser with no ready worker
* cannot display a push anyway, and the row is pruned on its next failed send.
*/
const UNREGISTER_TIMEOUT_MS = 3000
const unregisterThisDevice = async (): Promise<void> => {
try {
const token = await getCurrentToken()
if (token) await userService.clearFCMToken(token)
} catch (err) {
console.error('Failed to clear FCM token server-side:', err)
}
try {
const messaging = await getMessagingIfSupported()
if (!messaging) return
const { deleteToken } = await import('firebase/messaging')
await deleteToken(messaging)
} catch (err) {
console.error('Failed to delete FCM token:', err)
}
}
export const unregisterFCMToken = async (): Promise<void> => {
if (!('Notification' in window) || Notification.permission !== 'granted') return
await Promise.race([
unregisterThisDevice(),
new Promise<void>((resolve) => setTimeout(resolve, UNREGISTER_TIMEOUT_MS)),
])
}
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright 2024-2026 ChatterMate
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 type { User, UserGroup } from '@/types/user'
import api from './api'
export async function listGroups(): Promise<UserGroup[]> {
const response = await api.get('/groups')
return response.data
}
export async function createGroup(groupData: Partial<UserGroup>): Promise<UserGroup> {
const response = await api.post('/groups', groupData)
return response.data
}
export async function updateGroup(id: string, groupData: Partial<UserGroup>): Promise<UserGroup> {
const response = await api.put(`/groups/${id}`, groupData)
return response.data
}
export async function deleteGroup(id: string): Promise<void> {
await api.delete(`/groups/${id}`)
}
export async function addUserToGroup(groupId: string, userId: string): Promise<void> {
await api.post(`/groups/${groupId}/users/${userId}`)
}
export async function removeUserFromGroup(groupId: string, userId: string): Promise<void> {
await api.delete(`/groups/${groupId}/users/${userId}`)
}
export async function getGroupUsers(groupId: string): Promise<UserGroup> {
const response = await api.get(`user-groups/${groupId}`)
return response.data
}
+153
View File
@@ -0,0 +1,153 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import { apiPath } from '@/config/api'
/**
* Check if Jira is connected for the current organization
*/
export const checkJiraConnection = async () => {
try {
const response = await api.get('/jira/status')
return response.data
} catch (error) {
console.error('Error checking Jira connection:', error)
return { connected: false }
}
}
/**
* Get the Jira authorization URL
*/
export const getJiraAuthUrl = () => {
return apiPath('/jira/authorize')
}
/**
* Disconnect Jira integration
*/
export const disconnectJira = async () => {
try {
const response = await api.delete('/jira/disconnect')
return response.data
} catch (error) {
console.error('Error disconnecting Jira:', error)
throw error
}
}
/**
* Get Jira projects
*/
export const getJiraProjects = async () => {
try {
const response = await api.get('/jira/projects')
return response.data
} catch (error) {
console.error('Error getting Jira projects:', error)
throw error
}
}
/**
* Get Jira issue types for a project
*/
export const getJiraIssueTypes = async (projectKey: string) => {
try {
const response = await api.get(`/jira/projects/${projectKey}/issue-types`)
return response.data
} catch (error) {
console.error('Error getting Jira issue types:', error)
throw error
}
}
/**
* Save agent-to-Jira configuration
*/
export const saveAgentJiraConfig = async (agentId: string, config: {
enabled: boolean;
projectKey?: string;
issueTypeId?: string;
}) => {
try {
const response = await api.post(`/jira/agent-config/${agentId}`, config)
return response.data
} catch (error) {
console.error('Error saving agent Jira config:', error)
throw error
}
}
/**
* Get agent-to-Jira configuration
*/
export const getAgentJiraConfig = async (agentId: string) => {
try {
const response = await api.get(`/jira/agent-config/${agentId}`)
return response.data
} catch (error) {
console.error('Error getting agent Jira config:', error)
return { enabled: false }
}
}
/**
* Create a Jira ticket
*/
export const createJiraTicket = async (data: {
projectKey: string;
issueTypeId: string;
summary: string;
description: string;
priority?: string;
chatId?: string;
}) => {
try {
const response = await api.post('/jira/issues', data)
return response.data
} catch (error) {
console.error('Error creating Jira ticket:', error)
throw error
}
}
/**
* Get Jira priorities
*/
export const getJiraPriorities = async () => {
try {
const response = await api.get('/jira/priorities')
return response.data
} catch (error) {
console.error('Error getting Jira priorities:', error)
throw error
}
}
/**
* Check if priority field is available for a project and issue type
*/
export const checkPriorityAvailability = async (projectKey: string, issueTypeId: string) => {
try {
const response = await api.get(`/jira/projects/${projectKey}/issue-types/${issueTypeId}/has-priority`)
return response.data.hasPriority
} catch (error) {
console.error('Error checking priority availability:', error)
return false
}
}
+236
View File
@@ -0,0 +1,236 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import { apiErrorMessage } from '@/utils/apiError'
import type { KnowledgeUploadResponse, PaginatedKnowledgeResponse } from '@/types/knowledge'
export const knowledgeService = {
async uploadPdfFiles(
files: File[],
orgId: string,
agentId?: string,
onProgress?: (progress: number) => void,
): Promise<KnowledgeUploadResponse> {
try {
const formData = new FormData()
files.forEach((file) => formData.append('files', file))
formData.append('org_id', orgId.toString())
if (agentId) {
formData.append('agent_id', agentId.toString())
}
const response = await api.post('/knowledge/upload/pdf', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total && onProgress) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(progress)
}
},
})
return response.data
} catch (error: any) {
// Extract error message from response if available
const errorMessage = apiErrorMessage(error, 'Failed to upload PDFs')
throw new Error(errorMessage)
}
},
async addUrls(
orgId: string,
urls: string[],
agentId?: string,
onProgress?: (progress: number) => void,
maxLinks?: number,
crawlScope?: string,
): Promise<KnowledgeUploadResponse> {
try {
let completed = 0
const response = await api.post('/knowledge/add/urls', {
org_id: orgId,
agent_id: agentId,
pdf_urls: urls.filter((url) => url.toLowerCase().endsWith('.pdf')),
websites: urls.filter((url) => !url.toLowerCase().endsWith('.pdf')),
// Optional page cap for websites (e.g. 1 = "this page only").
...(maxLinks ? { max_links: maxLinks } : {}),
// How far link-following may go: 'path' | 'host' | 'domain'.
// Omitted means the backend default (the URL's own host).
...(crawlScope ? { crawl_scope: crawlScope } : {}),
})
if (onProgress) {
const interval = setInterval(() => {
completed += 10
onProgress(Math.min(completed, 100))
if (completed >= 100) clearInterval(interval)
}, 500)
}
return response.data
} catch (error: any) {
// Extract error message from response if available
const errorMessage = apiErrorMessage(error, 'Failed to add URLs')
throw new Error(errorMessage)
}
},
async getKnowledgeByOrganization(orgId: string, page: number = 1, pageSize: number = 10) {
const response = await api.get(`/knowledge/organization/${orgId}`, {
params: { page, page_size: pageSize },
})
return response.data
},
async linkToAgent(knowledgeId: number, agentId: string) {
const response = await api.post('/knowledge/link', null, {
params: { knowledge_id: knowledgeId, agent_id: agentId },
})
return response.data
},
async unlinkFromAgent(knowledgeId: number, agentId: string) {
const response = await api.delete('/knowledge/unlink', {
params: { knowledge_id: knowledgeId, agent_id: agentId },
})
return response.data
},
async getKnowledgeByAgent(
agentId: string,
page: number = 1,
pageSize: number = 10,
): Promise<PaginatedKnowledgeResponse> {
try {
const response = await api.get(`/knowledge/agent/${agentId}`, {
params: {
page,
page_size: pageSize,
},
})
return response.data
} catch (error) {
console.error('Failed to fetch knowledge:', error)
throw error
}
},
async deleteKnowledge(knowledgeId: number): Promise<unknown> {
const response = await api.delete(`/knowledge/${knowledgeId}`)
return response.data
},
async getAgentQueueItems(agentId: string) {
const response = await api.get(`/knowledge/queue/agent/${agentId}`)
return response.data
},
async getOrgQueueItems(orgId: string) {
const response = await api.get(`/knowledge/queue/organization/${orgId}`)
return response.data
},
async deleteQueueItem(queueId: number) {
const response = await api.delete(`/knowledge/queue/${queueId}`)
return response.data
},
async getKnowledgeContent(knowledgeId: number) {
const response = await api.get(`/knowledge/${knowledgeId}/content`)
return response.data
},
async updateChunkContent(knowledgeId: number, chunkId: string, content: string) {
// Don't encode chunkId here - axios will handle it
const response = await api.put(`/knowledge/${knowledgeId}/chunk/${encodeURIComponent(chunkId)}`, { content })
return response.data
},
async deleteChunk(knowledgeId: number, chunkId: string) {
const response = await api.delete(`/knowledge/${knowledgeId}/chunk/${chunkId}`)
return response.data
},
async addSubpage(knowledgeId: number, subpageName: string, content: string, url?: string) {
try {
const response = await api.post(`/knowledge/${knowledgeId}/subpage`, {
subpage_name: subpageName,
content,
...(url ? { url } : {}),
})
return response.data
} catch (error: any) {
// Surface the backend's specific reason (wrong domain, subpage limit
// reached, duplicate name…) instead of a generic axios message.
throw new Error(
apiErrorMessage(error, 'Failed to add sub-page'),
)
}
},
// Replace a whole sub-page's content and re-embed it. `pageId` is the page's
// base id (its URL/name) — encoded for the backend `:path` route param.
async updatePage(knowledgeId: number, pageId: string, content: string, title?: string) {
const response = await api.put(
`/knowledge/${knowledgeId}/page/${encodeURIComponent(pageId)}`,
{ content, title },
)
return response.data
},
// Delete an entire sub-page (all of its chunks) from a knowledge source.
async deletePage(knowledgeId: number, pageId: string) {
const response = await api.delete(
`/knowledge/${knowledgeId}/page/${encodeURIComponent(pageId)}`,
)
return response.data
},
// Add a sitemap source — the backend discovers and crawls its listed pages.
async addSitemap(orgId: string, url: string, agentId?: string): Promise<KnowledgeUploadResponse> {
try {
const response = await api.post('/knowledge/add/urls', {
org_id: orgId,
agent_id: agentId,
sitemaps: [url],
})
return response.data
} catch (error: any) {
const errorMessage =
apiErrorMessage(error, 'Failed to add sitemap')
throw new Error(errorMessage)
}
},
// Create a knowledge source from pasted text; indexed immediately (no crawl).
async addText(orgId: string, title: string, content: string, agentId?: string) {
try {
const response = await api.post('/knowledge/add/text', {
org_id: orgId,
title,
content,
agent_id: agentId,
})
return response.data
} catch (error: any) {
const errorMessage =
apiErrorMessage(error, 'Failed to add text source')
throw new Error(errorMessage)
}
},
}
+30
View File
@@ -0,0 +1,30 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { LeadCaptureConfig, LeadCaptureConfigUpdate } from '@/types/leadCapture'
export const leadCaptureService = {
async getConfig(agentId: string): Promise<LeadCaptureConfig> {
const response = await api.get(`/agent/${agentId}/lead-capture`)
return response.data
},
async updateConfig(agentId: string, config: LeadCaptureConfigUpdate): Promise<LeadCaptureConfig> {
const response = await api.put(`/agent/${agentId}/lead-capture`, config)
return response.data
},
}
+75
View File
@@ -0,0 +1,75 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { MCPTool, MCPToolCreate, MCPToolUpdate, MCPToolToAgent, AgentMCPTools, MCPToolTestResult, MCPToolReferences } from '@/types/mcp'
export const mcpService = {
// MCP Tool management
async getOrganizationMCPTools(enabledOnly: boolean = true): Promise<MCPTool[]> {
const response = await api.get('/mcp-tools', {
params: { enabled_only: enabledOnly }
})
return response.data
},
async getMCPTool(toolId: number): Promise<MCPTool> {
const response = await api.get(`/mcp-tools/${toolId}`)
return response.data
},
async createMCPTool(data: MCPToolCreate): Promise<MCPTool> {
const response = await api.post('/mcp-tools', data)
return response.data
},
async updateMCPTool(toolId: number, data: MCPToolUpdate): Promise<MCPTool> {
const response = await api.put(`/mcp-tools/${toolId}`, data)
return response.data
},
async getMCPToolReferences(toolId: number): Promise<MCPToolReferences> {
const response = await api.get(`/mcp-tools/${toolId}/references`)
return response.data
},
async deleteMCPTool(toolId: number): Promise<void> {
await api.delete(`/mcp-tools/${toolId}`)
},
async testMCPTool(toolId: number): Promise<MCPToolTestResult> {
const response = await api.post(`/mcp-tools/${toolId}/test`)
return response.data
},
// Agent-MCP Tool associations
async getAgentMCPTools(agentId: string): Promise<AgentMCPTools> {
const response = await api.get(`/mcp-tools/agent/${agentId}`)
return response.data
},
async addMCPToolToAgent(mcpToolId: number, agentId: string): Promise<MCPToolToAgent> {
const response = await api.post('/mcp-tools/agent-association', {
mcp_tool_id: mcpToolId,
agent_id: agentId
})
return response.data
},
async removeMCPToolFromAgent(mcpToolId: number, agentId: string): Promise<void> {
await api.delete(`/mcp-tools/agent-association/${mcpToolId}/${agentId}`)
}
}
+72
View File
@@ -0,0 +1,72 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
export interface Notification {
id: number
type: string
title: string
message: string
notification_metadata?: Record<string, any>
is_read: boolean
created_at: string
}
export interface NotificationSettings {
notify_new_chat: boolean
notify_chat_transfer: boolean
notify_chat_assigned: boolean
}
export const notificationService = {
async getNotifications(skip = 0, limit = 50): Promise<Notification[]> {
const response = await api.get(`/notifications?skip=${skip}&limit=${limit}`)
return response.data
},
async markAsRead(notificationId: number): Promise<void> {
await api.patch(`/notifications/${notificationId}/read`)
},
/** Clears every unread notification server-side, not just the fetched page. */
async markAllAsRead(): Promise<void> {
await api.post('/notifications/read-all')
},
async deleteNotification(notificationId: number): Promise<void> {
await api.delete(`/notifications/${notificationId}`)
},
async clearAll(): Promise<void> {
await api.delete('/notifications')
},
async getUnreadCount(): Promise<number> {
const response = await api.get('/notifications/unread-count')
return response.data.count
},
async getSettings(): Promise<NotificationSettings> {
const response = await api.get('/notifications/settings')
return response.data
},
async updateSettings(patch: Partial<NotificationSettings>): Promise<NotificationSettings> {
const response = await api.put('/notifications/settings', patch)
return response.data
},
}
+66
View File
@@ -0,0 +1,66 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { OrganizationCreate, OrganizationResponse, OrganizationUpdate } from '@/types/organization'
import type { AxiosError } from 'axios'
import { userService } from './user'
import type { Organization } from '@/types/organization'
interface ErrorResponse {
detail: string
}
export const createOrganization = async (data: OrganizationCreate) => {
const response = await api.post<OrganizationResponse>('/organizations', data)
// Store user info if available in response
if (response.data.user) {
userService.setCurrentUser(response.data.user)
}
return response.data
}
export async function getSetupStatus(): Promise<boolean> {
try {
const response = await api.get<{ is_setup: boolean }>('/organizations/setup-status')
return response.data.is_setup
} catch (err) {
console.error('Failed to fetch organization setup status:', err)
// In case of error, assume setup might be needed or system is unavailable
// Returning true might prevent setup, returning false allows setup check again.
return false
}
}
export const organizationService = {
async getOrganization(id: string): Promise<Organization> {
const response = await api.get(`/organizations/${id}`)
return response.data
},
async updateOrganization(id: string, data: OrganizationUpdate): Promise<Organization> {
const response = await api.patch(`/organizations/${id}`, data)
return response.data
},
async getOrganizationStats(id: string) {
const response = await api.get(`/organizations/${id}/stats`)
return response.data
}
}
+82
View File
@@ -0,0 +1,82 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { PeopleListResponse, PeopleStats, PersonDetail } from '@/types/people'
export interface CrmCustomerSync {
provider: string
contact_id: string | null
record_url: string | null
synced_at: string | null
}
export interface PersonCrmStatus {
connected_providers: string[]
synced: CrmCustomerSync[]
}
export interface ListPeopleParams {
stage?: string
search?: string
page?: number
page_size?: number
/** 'identified' (default) or 'anonymous' — the identity split */
view?: string
}
export const peopleService = {
async listPeople(params: ListPeopleParams = {}): Promise<PeopleListResponse> {
const response = await api.get('/people', { params })
return response.data
},
async getStats(): Promise<PeopleStats> {
const response = await api.get('/people/stats')
return response.data
},
async getPerson(customerId: string): Promise<PersonDetail> {
const response = await api.get(`/people/${customerId}`)
return response.data
},
async markAsCustomer(customerId: string): Promise<PersonDetail> {
const response = await api.post(`/people/${customerId}/mark-customer`)
return response.data
},
/** Explicit human edit — the one path allowed to correct a phone. */
async updatePerson(
customerId: string,
payload: { full_name?: string; phone?: string },
): Promise<PersonDetail> {
const response = await api.patch(`/people/${customerId}`, payload)
return response.data
},
/** CRM state for the drawer: connected CRMs + where this person is synced. */
async getCrmStatus(customerId: string): Promise<PersonCrmStatus> {
const response = await api.get(`/people/${customerId}/crm`)
return response.data
},
/** Manual "Sync now": push this person to every connected CRM. */
async syncToCrm(customerId: string): Promise<PersonCrmStatus> {
const response = await api.post(`/people/${customerId}/crm-sync`)
return response.data
},
}
+53
View File
@@ -0,0 +1,53 @@
/*
Copyright 2024-2026 ChatterMate
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 type { Role } from '@/types/user'
import api from './api'
export interface Permission {
id: number
name: string
description: string
}
export async function listRoles(): Promise<Role[]> {
const response = await api.get('/roles')
return response.data
}
export async function getRole(id: string): Promise<Role> {
const response = await api.get(`/roles/${id}`)
return response.data
}
export async function listPermissions(): Promise<Permission[]> {
const response = await api.get('/roles/permissions/all')
return response.data
}
export async function createRole(roleData: Partial<Role>): Promise<Role> {
const response = await api.post('roles', roleData)
return response.data
}
export async function updateRole(id: string, roleData: Partial<Role>): Promise<Role> {
const response = await api.put(`roles/${id}`, roleData)
return response.data
}
export async function deleteRole(id: string): Promise<void> {
await api.delete(`roles/${id}`)
}
+25
View File
@@ -0,0 +1,25 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { ChatDetail } from '@/types/chat'
export const sessionToAgentService = {
async takeoverChat(sessionId: string) {
const response = await api.post<ChatDetail>(`/sessions/${sessionId}/takeover`)
return response.data
}
}
+171
View File
@@ -0,0 +1,171 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import { apiPath } from '@/config/api'
/**
* Check if Shopify is connected for the current organization
*/
export const checkShopifyConnection = async () => {
try {
// Gets the first connected shop to determine if Shopify is connected
const response = await api.get('/shopify/shops?limit=1')
const shops = response.data
const isConnected = shops && shops.length > 0 && shops[0].is_installed
// Return connection status and shop domain if available
return {
connected: isConnected,
shop_domain: isConnected ? shops[0].shop_domain : '',
}
} catch (error) {
console.error('Error checking Shopify connection:', error)
return { connected: false }
}
}
/**
* Get the Shopify authorization URL
* User needs to provide their shop domain
*/
export const connectToShopify = (shopDomain: string) => {
// Derive the store handle from the input (whether the user typed a bare
// handle, a full *.myshopify.com host, or a URL) and rebuild the domain so
// only a genuine <handle>.myshopify.com host can ever be used.
const host = shopDomain.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0]
const handle = host.split('.')[0]
shopDomain = `${handle}.myshopify.com`
// Reject anything that isn't a well-formed Shopify shop domain.
if (!/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(shopDomain)) {
console.error('Invalid Shopify shop domain:', host)
return
}
// Redirect directly to the backend API for OAuth initiation
// This bypasses the frontend router which requires authentication
// apiPath() builds on the runtime API URL, which already includes /api/v1
window.location.href = apiPath(`/shopify/auth?shop=${encodeURIComponent(shopDomain)}`)
}
/**
* Disconnect Shopify integration
* This will remove all Shopify shops associated with the current organization
*/
export const disconnectShopify = async (shopId: string) => {
try {
// Assuming you'll create this endpoint
const response = await api.delete(`/shopify/shops/${shopId}`)
return response.data
} catch (error) {
console.error('Error disconnecting Shopify:', error)
throw error
}
}
/**
* Get all connected Shopify shops
*/
export const getShopifyShops = async () => {
try {
const response = await api.get('/shopify/shops')
return response.data
} catch (error) {
console.error('Error getting Shopify shops:', error)
throw error
}
}
/**
* Save agent-to-Shopify configuration
*/
export const saveAgentShopifyConfig = async (agentId: string, config: {
shop_id?: string;
enabled: boolean;
}) => {
try {
const response = await api.post(`/shopify/agent-config/${agentId}`, config)
return response.data
} catch (error) {
console.error('Error saving agent Shopify config:', error)
throw error
}
}
/**
* Get agent-to-Shopify configuration
*/
export const getAgentShopifyConfig = async (agentId: string) => {
try {
const response = await api.get(`/shopify/agent-config/${agentId}`)
return response.data
} catch (error) {
console.error('Error getting agent Shopify config:', error)
return { enabled: false }
}
}
/**
* Enable Shopify for multiple agents
*/
export const enableShopifyForAgents = async (agentIds: string[], shopId: string) => {
try {
const savePromises = agentIds.map(agentId =>
saveAgentShopifyConfig(agentId, {
shop_id: shopId,
enabled: true
})
)
await Promise.all(savePromises)
return { success: true, count: agentIds.length }
} catch (error) {
console.error('Error enabling Shopify for agents:', error)
throw error
}
}
/**
* Get shop information from backend during auth flow
*/
export const getShopAuthInfo = async (shopDomain: string, embedded: boolean = true) => {
try {
const response = await api.get(`/shopify/auth?shop=${encodeURIComponent(shopDomain)}&embedded=${embedded ? 1 : 0}`)
return response.data
} catch (error) {
console.error('Error getting shop auth info:', error)
throw error
}
}
/**
* Get shop configuration status (agents connected, widget ID, etc.)
*/
export const getShopConfigStatus = async (shopDomain: string, shopId?: string) => {
try {
const params = new URLSearchParams({ shop: shopDomain })
if (shopId) {
params.append('shop_id', shopId)
}
const response = await api.get(`/shopify/shop-config-status?${params.toString()}`)
return response.data
} catch (error) {
console.error('Error getting shop config status:', error)
throw error
}
}
+122
View File
@@ -0,0 +1,122 @@
/*
Copyright 2024-2026 ChatterMate
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 { io, Socket } from 'socket.io-client'
import { ref } from 'vue'
import emitter from '@/utils/emitter'
import { getWsUrl } from '@/config/api'
class SocketService {
private socket: Socket | null = null
private _isConnected = ref(false)
private _error = ref<string | null>(null)
private eventEmitter = emitter
get isConnected() {
return this._isConnected
}
get error() {
return this._error
}
connect(namespace: string = '/agent') {
if (this.socket?.connected) return
// Use the runtime-resolved WS URL (window.APP_CONFIG → VITE_WS_URL → localhost),
// same as every other consumer. Reading import.meta.env directly baked the
// published image to ws://localhost:8000 and broke self-hosted dashboards.
const apiUrl = getWsUrl()
this.socket = io(apiUrl + namespace, {
transports: ['websocket', 'polling'],
withCredentials: true,
autoConnect: true,
})
this.socket.on('connect', () => {
this._isConnected.value = true
this._error.value = null
console.log('Socket connected')
// Emit reconnection event for components to reattach listeners
this.eventEmitter.emit('socket:reconnected')
})
this.socket.on('connection_success', (data) => {
console.log('Connection success:', data)
})
this.socket.on('disconnect', () => {
this._isConnected.value = false
console.log('Socket disconnected')
})
this.socket.on('error', (error) => {
this._error.value = error.error
console.error('Socket error:', error)
})
}
disconnect() {
if (this.socket) {
this.socket.disconnect()
this.socket = null
}
}
emit(event: string, data: unknown) {
if (!this.socket) {
console.error('Socket not initialized')
return
}
this.socket.emit(event, data)
}
emitPing(event: string) {
if (!this.socket) {
console.error('Socket not initialized')
return
}
this.socket.emit(event)
}
on<T>(event: string, callback: (data: T) => void) {
if (!this.socket) {
console.error('Socket not initialized')
return
}
this.socket.on(event, callback as (...args: any[]) => void)
}
off<T>(event: string, callback?: (data: T) => void) {
if (!this.socket) {
console.warn('Socket not initialized')
return
}
this.socket.off(event, callback as (...args: any[]) => void)
}
// Add methods for reconnection event handling
onReconnect(callback: () => void) {
this.eventEmitter.on('socket:reconnected', callback)
}
offReconnect(callback: () => void) {
this.eventEmitter.off('socket:reconnected', callback)
}
}
// Create a singleton instance
export const socketService = new SocketService()
+265
View File
@@ -0,0 +1,265 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type {
DbConnector,
DbConnectorPayload,
DbConnectorTable,
InvestigationDetail,
InvestigationRun,
RcaDocument,
Ticket,
TicketActivity,
TicketCreatePayload,
TicketDetail,
TicketListResponse,
TicketProposal,
TicketSettings,
TicketStats,
TicketUpdatePayload,
} from '@/types/ticket'
function errorMessage(error: any, fallback: string): Error {
return new Error(error.response?.data?.detail || fallback)
}
export const ticketService = {
async listTickets(params: {
status?: string
priority?: string
assignee_id?: string
unassigned?: boolean
ai_state?: string
search?: string
sort?: string
page?: number
page_size?: number
} = {}): Promise<TicketListResponse> {
try {
const response = await api.get('/tickets', { params })
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load tickets')
}
},
async getStats(): Promise<TicketStats> {
try {
const response = await api.get('/tickets/stats')
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load ticket stats')
}
},
async getTicket(id: string): Promise<TicketDetail> {
try {
const response = await api.get(`/tickets/${id}`)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load the ticket')
}
},
async createTicket(payload: TicketCreatePayload): Promise<TicketDetail> {
try {
const response = await api.post('/tickets', payload)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to create the ticket')
}
},
async updateTicket(id: string, patch: TicketUpdatePayload): Promise<Ticket> {
try {
const response = await api.patch(`/tickets/${id}`, patch)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to update the ticket')
}
},
async addComment(id: string, body: string, isInternal = true): Promise<TicketActivity> {
try {
const response = await api.post(`/tickets/${id}/comments`, {
body,
is_internal: isInternal,
})
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to post the comment')
}
},
async resolveTicket(
id: string,
payload: { outcome?: string; resolution_summary?: string; customer_message?: string } = {},
): Promise<Ticket> {
try {
const response = await api.post(`/tickets/${id}/resolve`, { outcome: 'fixed', ...payload })
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to resolve the ticket')
}
},
async reopenTicket(id: string, reason?: string): Promise<Ticket> {
try {
const response = await api.post(`/tickets/${id}/reopen`, { reason })
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to reopen the ticket')
}
},
async investigate(
id: string,
options: { run_type?: 'triage' | 'investigation'; context_note?: string } = {},
): Promise<InvestigationRun> {
try {
const response = await api.post(`/tickets/${id}/investigate`, options)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to start the AI run')
}
},
async getInvestigation(id: string): Promise<InvestigationDetail> {
try {
const response = await api.get(`/tickets/${id}/investigation`)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load the investigation')
}
},
async updateRca(
id: string,
patch: { customer_summary?: string; mark_reviewed?: boolean },
): Promise<RcaDocument> {
try {
const response = await api.patch(`/tickets/${id}/rca`, patch)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to save the RCA')
}
},
async sendRcaToCustomer(id: string): Promise<TicketActivity> {
try {
const response = await api.post(`/tickets/${id}/rca/send-customer`)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to send the summary to the customer')
}
},
async approveProposal(id: string): Promise<TicketProposal> {
try {
const response = await api.post(`/tickets/${id}/proposal/approve`)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to approve the proposal')
}
},
async rejectProposal(
id: string,
reason?: string,
reinvestigate = false,
): Promise<TicketProposal> {
try {
const response = await api.post(`/tickets/${id}/proposal/reject`, {
reason,
reinvestigate,
})
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to reject the proposal')
}
},
async getTicketBySession(sessionId: string): Promise<Ticket | null> {
try {
const response = await api.get(`/tickets/by-session/${sessionId}`)
return response.data || null
} catch {
// 403 (plan gate) or transient errors — the inbox card just hides.
return null
}
},
async draftFromSession(sessionId: string): Promise<{ title: string; description: string }> {
try {
const response = await api.get(`/tickets/draft-from-session/${sessionId}`)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to draft from the conversation')
}
},
async getSettings(): Promise<TicketSettings> {
try {
const response = await api.get('/tickets/settings')
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to load ticketing settings')
}
},
async updateSettings(patch: Partial<TicketSettings>): Promise<TicketSettings> {
try {
const response = await api.put('/tickets/settings', patch)
return response.data
} catch (error: any) {
throw errorMessage(error, 'Failed to save ticketing settings')
}
},
}
export const dbConnectorService = {
async list(): Promise<DbConnector[]> {
const response = await api.get('/ticket-db-connectors')
return response.data
},
async discover(
payload: DbConnectorPayload,
): Promise<{ ok: boolean; error?: string; tables: DbConnectorTable[] }> {
const response = await api.post('/ticket-db-connectors/discover', payload)
return response.data
},
async create(payload: DbConnectorPayload): Promise<DbConnector> {
const response = await api.post('/ticket-db-connectors', payload)
return response.data
},
async update(id: string, patch: Partial<DbConnectorPayload>): Promise<DbConnector> {
const response = await api.patch(`/ticket-db-connectors/${id}`, patch)
return response.data
},
async remove(id: string): Promise<void> {
await api.delete(`/ticket-db-connectors/${id}`)
},
async test(id: string): Promise<{ ok: boolean; error?: string; tables: DbConnectorTable[] }> {
const response = await api.post(`/ticket-db-connectors/${id}/test`)
return response.data
},
}
+108
View File
@@ -0,0 +1,108 @@
/*
Copyright 2024-2026 ChatterMate
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 type { Role, User } from '@/types/user'
import api from './api'
import { uploadProfilePic } from './users'
export const userService = {
userInfo: null as User | null,
setCurrentUser(user: User) {
this.userInfo = user
localStorage.setItem('user_info', JSON.stringify(user))
},
getUserId(): string {
return this.getCurrentUser()?.id || ''
},
getCurrentUser(): User | null {
if (this.userInfo) return this.userInfo
const storedUser = localStorage.getItem('user_info')
if (storedUser) {
try {
this.userInfo = JSON.parse(storedUser)
return this.userInfo
} catch (err) {
console.error('Failed to parse user info:', err)
return null
}
}
return null
},
clearCurrentUser() {
this.userInfo = null
localStorage.removeItem('user_info')
},
isAuthenticated(): boolean {
return !!this.getCurrentUser()
},
getUserName(): string {
return this.getCurrentUser()?.full_name || ''
},
getUserEmail(): string {
return this.getCurrentUser()?.email || ''
},
getUserRole(): string {
return this.getCurrentUser()?.role?.name || 'User'
},
async updateFCMToken(token: string): Promise<unknown> {
const response = await api.post('/users/token/fcm-token', { token })
return response.data
},
// Unregisters one device. The token is required: clearing every token for
// the account would knock the user's other signed-in devices off push.
async clearFCMToken(token: string): Promise<unknown> {
try {
const response = await api.delete('/users/token/fcm-token', { data: { token } })
return response.data
} catch (error) {
console.error('Failed to clear FCM token:', error)
// Never block logout on this — a token left behind is pruned server-side
// on its next failed delivery.
}
},
async updateProfile(data: {
full_name?: string
email?: string
current_password?: string
password?: string
}) {
try {
const response = await api.patch('/users/me', data)
this.setCurrentUser(response.data)
return response.data
} catch (error: any) {
throw new Error(error.response?.data?.detail || 'Failed to update profile')
}
},
async uploadProfilePic(formData: FormData): Promise<void> {
const user = await uploadProfilePic(formData)
this.setCurrentUser(user)
},
}
+137
View File
@@ -0,0 +1,137 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { ChatScopeFields, User } from '@/types/user'
export interface TeamAgentStats {
id: string
full_name: string
email: string
profile_pic?: string | null
is_online: boolean
last_seen?: string | null
is_active: boolean
role?: string | null
is_admin: boolean
groups: string[]
active_chats: number
resolved_chats: number
capacity: number
}
export interface TeamKpis {
team_size: number
admins: number
agents: number
online_now: number
active_chats: number
total_capacity: number
waiting_handoff: number
oldest_wait_minutes: number
}
export interface TeamOverview {
kpis: TeamKpis
agents: TeamAgentStats[]
}
export async function listUsers(): Promise<User[]> {
const response = await api.get('users')
return response.data
}
/** A colleague as the inbox needs them, from GET /users/teammates. */
export interface Teammate {
id: string
full_name: string | null
email: string
profile_pic?: string | null
is_online?: boolean
}
/**
* Who a conversation can be handed to.
*
* The inbox used to call GET /users for this, which needs manage_users — so an
* agent got a 403 and an empty Reassign dropdown for an action the API would
* have allowed. This endpoint is gated on the inbox permissions instead, and
* returns no roles or permission lists.
*/
export async function listTeammates(): Promise<Teammate[]> {
const response = await api.get('users/teammates')
return response.data
}
/**
* Aggregated Human-Agents dashboard (KPIs + per-agent load/resolved).
* Returns null if the backend endpoint isn't available yet (graceful fallback).
*/
export async function getTeamOverview(): Promise<TeamOverview | null> {
try {
const response = await api.get('users/team-overview')
return response.data
} catch (err) {
return null
}
}
export async function getUser(id: string): Promise<User> {
const response = await api.get(`/users/${id}`)
return response.data
}
export async function createUser(userData: Partial<User> & ChatScopeFields): Promise<User> {
const response = await api.post('users', userData)
return response.data
}
export async function updateUser(id: string, userData: Partial<User> & ChatScopeFields): Promise<User> {
const response = await api.put(`users/${id}`, userData)
return response.data
}
export async function deleteUser(id: string): Promise<void> {
await api.delete(`users/${id}`)
}
/**
* Admin-set password for another user in the organization. The admin never
* knows the old one, so nothing is verified here — `manage_users` is the gate,
* and the API refuses to reset your own password (that goes through profile
* settings, which does ask for the current password).
*/
export async function resetUserPassword(id: string, newPassword: string): Promise<void> {
await api.post(`users/${id}/reset-password`, { new_password: newPassword })
}
export async function updateUserProfile(id: string, profileData: Partial<User>): Promise<User> {
const response = await api.put(`users/${id}/profile`, profileData)
return response.data
}
export async function uploadProfilePic(formData: FormData): Promise<User> {
const response = await api.post('/users/me/profile-pic', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
}
export async function updateUserStatus(id: string, isOnline: boolean): Promise<void> {
await api.post(`/users/${id}/status`, { is_online: isOnline })
}
+59
View File
@@ -0,0 +1,59 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type {
WidgetApp,
WidgetAppCreate,
WidgetAppUpdate,
WidgetAppWithKey,
WidgetAppListResponse
} from '@/types/widget-app'
export const widgetAppService = {
async listApps(includeInactive: boolean = false): Promise<WidgetAppListResponse> {
const response = await api.get('/widget-apps', {
params: { include_inactive: includeInactive }
})
return response.data
},
async getApp(appId: string): Promise<WidgetApp> {
const response = await api.get(`/widget-apps/${appId}`)
return response.data
},
async createApp(data: WidgetAppCreate): Promise<WidgetAppWithKey> {
const response = await api.post('/widget-apps', data)
return response.data
},
async updateApp(appId: string, data: WidgetAppUpdate): Promise<WidgetApp> {
const response = await api.patch(`/widget-apps/${appId}`, data)
return response.data
},
async deleteApp(appId: string, hardDelete: boolean = false): Promise<void> {
await api.delete(`/widget-apps/${appId}`, {
params: { hard_delete: hardDelete }
})
},
async regenerateApiKey(appId: string): Promise<WidgetAppWithKey> {
const response = await api.post(`/widget-apps/${appId}/regenerate-key`)
return response.data
}
}
+53
View File
@@ -0,0 +1,53 @@
/*
Copyright 2024-2026 ChatterMate
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 api from '@/services/api'
import type { Widget, WidgetCreate } from '@/types/widget'
class WidgetService {
private baseUrl = '/widgets'
async getWidgets(): Promise<Widget[]> {
const response = await api.get(this.baseUrl)
return response.data
}
async getWidget(id: string): Promise<Widget> {
const response = await api.get(`${this.baseUrl}/${id}`)
return response.data
}
async createWidget(data: WidgetCreate): Promise<Widget> {
const response = await api.post(this.baseUrl, data)
return response.data
}
/** The widget the setup wizard installs: one per agent, named after it. */
async createAgentWidget(agentId: string, agentName: string): Promise<Widget> {
return this.createWidget({ name: `${agentName} Widget`, agent_id: agentId })
}
async deleteWidget(id: string): Promise<void> {
await api.delete(`${this.baseUrl}/${id}`)
}
async getWidgetDetails(id: string): Promise<Widget> {
const response = await api.get(`${this.baseUrl}/${id}/details`)
return response.data
}
}
export const widgetService = new WidgetService()
+57
View File
@@ -0,0 +1,57 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { WorkflowCreate, WorkflowResponse } from '@/types/workflow'
import { WorkflowStatus } from '@/types/workflow'
export const workflowService = {
async createWorkflow(data: WorkflowCreate): Promise<WorkflowResponse> {
const response = await api.post('/workflow', data)
return response.data
},
async getWorkflowByAgent(agentId: string): Promise<WorkflowResponse | null> {
try {
const response = await api.get(`/workflow/agent/${agentId}`)
return response.data
} catch (error: any) {
if (error.response?.status === 404) {
return null
}
throw error
}
},
async updateWorkflow(workflowId: string, data: Partial<WorkflowCreate>): Promise<WorkflowResponse> {
const response = await api.put(`/workflow/${workflowId}`, data)
return response.data
},
async deleteWorkflow(workflowId: string): Promise<void> {
await api.delete(`/workflow/${workflowId}`)
},
async publishWorkflow(workflowId: string): Promise<WorkflowResponse> {
const response = await api.put(`/workflow/${workflowId}`, { status: WorkflowStatus.PUBLISHED })
return response.data
},
async unpublishWorkflow(workflowId: string): Promise<WorkflowResponse> {
const response = await api.put(`/workflow/${workflowId}`, { status: WorkflowStatus.DRAFT })
return response.data
}
}
+232
View File
@@ -0,0 +1,232 @@
/*
Copyright 2024-2026 ChatterMate
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 api from './api'
import type { WorkflowNodesResponse, WorkflowNode, WorkflowConnection } from '@/types/workflow'
import { workflowCacheStorage } from '@/utils/storage'
export const workflowNodeService = {
/**
* Get all nodes and connections for a workflow (with cache)
*/
async getWorkflowNodes(workflowId: string, useCache: boolean = true): Promise<WorkflowNodesResponse> {
// Try to get from cache first if useCache is true
if (useCache) {
const cached = workflowCacheStorage.getWorkflowCache(workflowId)
if (cached) {
console.log('Using cached workflow data:', cached)
return cached
}
}
// Fetch from API
const response = await api.get(`/workflow/${workflowId}/nodes`)
const data = response.data
// Store in cache
workflowCacheStorage.setWorkflowCache(workflowId, data)
return data
},
/**
* Clear cache and reload from server
*/
async refreshWorkflowNodes(workflowId: string): Promise<WorkflowNodesResponse> {
workflowCacheStorage.clearWorkflowCache(workflowId)
return this.getWorkflowNodes(workflowId, false)
},
/**
* Create a new workflow node
*/
async createWorkflowNode(workflowId: string, nodeData: Partial<WorkflowNode>): Promise<WorkflowNode> {
const response = await api.post(`/workflow/${workflowId}/nodes`, nodeData)
return response.data
},
/**
* Update a workflow node
*/
async updateWorkflowNode(workflowId: string, nodeId: string, nodeData: Partial<WorkflowNode>): Promise<WorkflowNode> {
const response = await api.put(`/workflow/${workflowId}/nodes/${nodeId}`, nodeData)
return response.data
},
/**
* Delete a workflow node
*/
async deleteWorkflowNode(workflowId: string, nodeId: string): Promise<void> {
await api.delete(`/workflow/${workflowId}/nodes/${nodeId}`)
},
/**
* Replace all workflow nodes and connections (complete cache replacement)
*/
async replaceWorkflowNodes(workflowId: string, data: {
nodes: Partial<WorkflowNode>[]
connections: Partial<WorkflowConnection>[]
}): Promise<WorkflowNodesResponse> {
const response = await api.put(`/workflow/${workflowId}/nodes`, data)
const result = response.data
// Update cache with the response
workflowCacheStorage.setWorkflowCache(workflowId, result)
return result
},
/**
* Filter out blank/empty values from an object
*/
filterBlankValues(obj: any): any {
const filtered: any = {}
for (const [key, value] of Object.entries(obj)) {
// Skip null, undefined, empty strings, and empty arrays
if (value !== null && value !== undefined && value !== '' &&
!(Array.isArray(value) && value.length === 0)) {
// For objects, recursively filter
if (typeof value === 'object' && !Array.isArray(value)) {
const filteredObj = this.filterBlankValues(value)
// Only include if the filtered object has properties
if (Object.keys(filteredObj).length > 0) {
filtered[key] = filteredObj
}
} else {
filtered[key] = value
}
}
}
return filtered
},
/**
* Save entire workflow cache to backend (replaces all existing data)
*/
async saveWorkflowCache(workflowId: string): Promise<WorkflowNodesResponse> {
const cached = workflowCacheStorage.getWorkflowCache(workflowId)
if (!cached) {
throw new Error('No cached data found for workflow')
}
// Filter out blank values from nodes and connections
const filteredNodes = cached.nodes.map((node: any) => this.filterBlankValues(node))
const filteredConnections = cached.connections.map((conn: any) => this.filterBlankValues(conn))
return this.replaceWorkflowNodes(workflowId, {
nodes: filteredNodes,
connections: filteredConnections
})
},
/**
* Update a single workflow node with its properties
*/
async updateSingleNode(workflowId: string, nodeId: string, data: {
name?: string;
description?: string;
position?: { x: number; y: number };
config?: any;
message_text?: string;
system_prompt?: string;
temperature?: number;
model_id?: number;
form_fields?: any[];
condition_expression?: string;
action_type?: string;
action_config?: any;
transfer_rules?: any;
wait_duration?: number;
wait_until_condition?: string;
}): Promise<WorkflowNode> {
const response = await api.put(`/workflow/${workflowId}/nodes/${nodeId}`, data);
const updatedNode = response.data;
// Update cache with the updated node
workflowCacheStorage.updateNodeInCache(workflowId, updatedNode);
return updatedNode;
},
/**
* Create a new workflow node in cache only
*/
createNodeInCache(workflowId: string, nodeData: any): void {
workflowCacheStorage.updateNodeInCache(workflowId, nodeData)
},
/**
* Update a node in cache only
*/
updateNodeInCache(workflowId: string, nodeData: any): void {
workflowCacheStorage.updateNodeInCache(workflowId, nodeData)
},
/**
* Delete a node from cache only
*/
deleteNodeFromCache(workflowId: string, nodeId: string): void {
workflowCacheStorage.removeNodeFromCache(workflowId, nodeId)
},
/**
* Add connection to cache only
*/
addConnectionToCache(workflowId: string, connectionData: any): void {
workflowCacheStorage.addConnectionToCache(workflowId, connectionData)
},
/**
* Remove connection from cache only
*/
removeConnectionFromCache(workflowId: string, connectionId: string): void {
workflowCacheStorage.removeConnectionFromCache(workflowId, connectionId)
},
/**
* Get a single workflow node
*/
async getNode(workflowId: string, nodeId: string): Promise<WorkflowNode> {
const response = await api.get(`/workflow/${workflowId}/nodes/${nodeId}`);
return response.data;
},
/**
* Create a workflow connection
*/
async createWorkflowConnection(workflowId: string, connectionData: Partial<WorkflowConnection>): Promise<WorkflowConnection> {
const response = await api.post(`/workflow/${workflowId}/connections`, connectionData)
return response.data
},
/**
* Update a workflow connection
*/
async updateWorkflowConnection(workflowId: string, connectionId: string, connectionData: Partial<WorkflowConnection>): Promise<WorkflowConnection> {
const response = await api.put(`/workflow/${workflowId}/connections/${connectionId}`, connectionData)
return response.data
},
/**
* Delete a workflow connection
*/
async deleteWorkflowConnection(workflowId: string, connectionId: string): Promise<void> {
await api.delete(`/workflow/${workflowId}/connections/${connectionId}`)
}
}
+28
View File
@@ -0,0 +1,28 @@
/*
Copyright 2024-2026 ChatterMate
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 { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
+132
View File
@@ -0,0 +1,132 @@
/*
Copyright 2024-2026 ChatterMate
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 type { UserGroup } from "./user"
export interface Agent {
id: string // UUID
name: string
display_name: string | null
description: string | null
agent_type: string
instructions: string[]
is_active: boolean
organization_id: string // UUID
transfer_to_human: boolean
/** Off means the AI never replies: every chat goes straight to the team. */
ai_replies_enabled: boolean
/** The AI's choices as tappable buttons on WhatsApp, Messenger, Instagram and Telegram. */
channel_quick_replies: boolean
ask_for_rating: boolean
handoff_collect_email: boolean
handoff_collect_name: boolean
enable_rate_limiting: boolean
overall_limit_per_ip: number
requests_per_sec: number
use_workflow: boolean
active_workflow_id: string | null
allow_attachments: boolean
// Allowed attachment type categories: 'images', 'documents', 'office', 'text'
allowed_attachment_types: string[] | null
require_token_auth: boolean
ticketing_enabled: boolean
/** Tenant-editable scope rule; null uses the platform default. */
guardrail_prompt?: string | null
guardrail_enabled?: boolean
knowledge: Array<{
id: number
name: string
type: string
}>
customization?: AgentCustomization
groups?: UserGroup[]
}
export type AgentResponse = Agent
// Add AgentUpdate type for update operations
export interface AgentUpdate {
display_name?: string | null
instructions?: string[]
is_active?: boolean
transfer_to_human?: boolean
ai_replies_enabled?: boolean
channel_quick_replies?: boolean
ask_for_rating?: boolean
handoff_collect_email?: boolean
handoff_collect_name?: boolean
enable_rate_limiting?: boolean
overall_limit_per_ip?: number
requests_per_sec?: number
use_workflow?: boolean
active_workflow_id?: string | null
allow_attachments?: boolean
allowed_attachment_types?: string[] | null
require_token_auth?: boolean
ticketing_enabled?: boolean
guardrail_prompt?: string | null
guardrail_enabled?: boolean
customization?: AgentCustomization
}
export interface ChatMessage {
id?: string
role: 'user' | 'bot' | 'error' | 'agent'
content: string
isSetup?: boolean
setupStep?: 'provider' | 'model' | 'key'
options?: Array<{ value: string; label: string }>
showKeyInput?: boolean
showModelInput?: boolean
timestamp?: Date
attributes?: Record<string, unknown>
agent_name?: string
transfer_to_human?: boolean
message_type?: string
}
export type ChatStyle = 'CHATBOT' | 'ASK_ANYTHING' | 'GLASS' | 'TERMINAL' | 'PLAYFUL' | 'CALM_MINT' | 'AURORA' | 'SUNRISE'
export type WidgetPosition = 'FLOATING' | 'FIXED'
export interface AgentCustomization {
id: number
agent_id: string // UUID
photo_url?: string
chat_background_color?: string
chat_bubble_color?: string
chat_text_color?: string
icon_url?: string
icon_color?: string
accent_color?: string
font_family?: string
custom_css?: string
customization_metadata?: Record<string, any>
chat_style?: ChatStyle
widget_position?: WidgetPosition
welcome_title?: string
welcome_subtitle?: string
welcome_message?: string
chat_initiation_messages?: string[]
quick_actions?: string[]
show_citations?: boolean
collect_email?: boolean
show_ai_disclaimer?: boolean
allow_new_chat?: boolean
}
export interface AgentWithCustomization extends AgentResponse {
customization?: AgentCustomization
}
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export interface AIModel {
value: string
label: string
}
export interface AIProvider {
value: string
label: string
requires_api_key: boolean
custom_allowed: boolean
api_key_url: string
models: AIModel[]
requires_base_url: boolean
}
export interface AIConfig {
id: number
organization_id: string // UUID
model_type: string
model_name: string
is_active: boolean
settings: Record<string, unknown>
}
export type AIConfigResponse = AIConfig
export interface AISetupResponse {
message: string
config: AIConfigResponse
}
export interface SetupStep {
provider?: boolean
model?: boolean
key?: boolean
}
+127
View File
@@ -0,0 +1,127 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export interface CustomerInfo {
id: string
email: string
full_name?: string
// Integrator-supplied fields (e.g. student_name, center_name) set via
// POST /generate-token's `custom_data`.
meta_data?: Record<string, unknown>
}
export interface Message {
id?: number
message?: string
message_type: string
created_at: string
session_id: string
// Live replies are revealed client-side with a typewriter effect.
stream?: boolean
attributes?: Record<string, any>
// Set on a message sent from the inbox until the backend reports its id
client_id?: string
// Channel messages: the platform's id and its latest delivery receipt
external_message_id?: string | null
channel_status?: 'sent' | 'delivered' | 'read' | 'failed' | null
user_name?: string
agent_name?: string
end_chat?: boolean
end_chat_reason?: string
end_chat_description?: string
attachments?: Array<{
id: number
filename: string
file_url: string
content_type: string
file_size: number
}>
// Updated shopify_output to match Conversation structure
shopify_output?: {
products: Array<{
id: string
title: string
price: string
image?: ShopifyProductImage // Reuse existing ShopifyProductImage type
}>
}
// Knowledge-base citations used to ground this message (rendered as source chips)
sources?: Array<{ name: string; type: string }>
}
export interface ShopifyProductImage {
src?: string
alt?: string
}
export interface Conversation {
customer: CustomerInfo
agent: {
id: string
name: string
display_name: string | null
/** False when this agent never answers with AI — its chats wait for a person. */
ai_replies_enabled?: boolean
}
last_message: string | null
updated_at: string
message_count: number
session_id: string
user_id: string | null
/** Human agent holding the chat; absent while the AI still has it. */
user_name?: string | null
group_id?: string | null
status: 'open' | 'closed' | 'transferred'
channel?: string
attributes?: {
message_type?: string
shopify_output?: {
products: Array<{
id: string
title: string
price: string
image?: {
src: string
alt?: string
}
}>
}
}
}
export interface ChatDetail {
customer: CustomerInfo
agent: {
id: string
name: string
display_name: string | null
/** False when this agent never answers with AI — its chats wait for a person. */
ai_replies_enabled?: boolean
}
session_id: string
messages: Message[]
created_at: string
updated_at: string
user_id: string | null
user_name?: string | null
group_id: string | null
status: 'open' | 'transferred' | 'closed'
channel?: string
/** The connected account this conversation arrived on; absent for web chats. */
channel_account_id?: string | null
}
+169
View File
@@ -0,0 +1,169 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export type FaqStatus = 'draft' | 'published'
export interface FaqItem {
id: string
question: string
answer: string
category: string
slug?: string | null
/** The URL path this article is served at, carried over from a help center
* the org migrated from. Null means it lives at /a/{slug}. */
url_path?: string | null
/** Where an imported article was fetched from. Provenance only. */
source_url?: string | null
/** Per-article SEO overrides. Null means the public page derives them from
* the question and answer. */
meta_title?: string | null
meta_description?: string | null
status: FaqStatus
knowledge_id: number | null
source_label: string | null
helpful_yes?: number
helpful_no?: number
created_at?: string | null
updated_at?: string | null
}
/** Editable per-article SEO fields. Blank strings are sent as-is and cleared
* server-side, restoring the derived defaults. */
export type FaqSeoFields = Pick<FaqItem, 'slug' | 'url_path' | 'meta_title' | 'meta_description'>
export interface FaqPagination {
total: number
page: number
page_size: number
total_pages: number
}
export interface FaqListResponse {
faqs: FaqItem[]
pagination: FaqPagination
}
export type FaqImportMode = 'qa' | 'articles' | 'pdf'
export type FaqJobStatus = 'pending' | 'processing' | 'completed' | 'failed'
export type FaqJobStage =
| 'not_started'
| 'analyzing_sources'
| 'extracting'
| 'drafting'
| 'grouping'
| 'completed'
export interface GenerationSource {
id: number
name: string
source_type: string
has_faqs: boolean
pages: number
estimated_calls: number
}
export interface GenerateEstimate {
total_sources: number
new_sources: number
pages: number
estimated_calls: number
metered: boolean
remaining_credits: number | null
sources: GenerationSource[]
}
export interface FaqGenerationJob {
id: number
job_type: 'generate_all' | 'generate_source' | 'import_url' | 'import_articles' | 'import_pdf'
status: FaqJobStatus
stage: FaqJobStage
progress_percentage: number
faqs_created: number
source_url: string | null
error: string | null
created_at?: string | null
}
export interface HelpCenterHeaderLink {
label: string
url: string
}
export interface HelpCenterAgentOption {
id: string
name: string
has_widget: boolean
}
export type DomainStatus = 'unverified' | 'pending' | 'verified'
export type SslStatus = 'none' | 'pending' | 'active' | 'failed'
export interface HelpCenterDnsRecord {
type: 'CNAME' | 'TXT'
host: string
value: string
verified: boolean
}
export interface HelpCenterDomain {
custom_domain: string | null
domain_status: DomainStatus
ssl_status: SslStatus
records: HelpCenterDnsRecord[]
domain_verified_at: string | null
}
export interface HelpCenterSettings {
enabled: boolean
slug: string | null
title: string | null
description: string | null
logo_url: string | null
favicon_url: string | null
brand_color: string
header_links: HelpCenterHeaderLink[]
cta_text: string | null
cta_url: string | null
cta_enabled: boolean
auto_generate: boolean
agent_id: string | null
ai_search_enabled: boolean
chat_widget_enabled: boolean
live_url: string | null
published_count: number
plan_allowed: boolean
agents: HelpCenterAgentOption[]
domain: HelpCenterDomain | null
}
export type HelpCenterSettingsUpdate = Partial<
Pick<
HelpCenterSettings,
| 'enabled'
| 'title'
| 'description'
| 'brand_color'
| 'header_links'
| 'cta_text'
| 'cta_url'
| 'cta_enabled'
| 'auto_generate'
| 'agent_id'
| 'ai_search_enabled'
| 'chat_widget_enabled'
>
>
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
declare global {
interface Window {
APP_CONFIG?: {
// API URLs
API_URL?: string
WS_URL?: string
WIDGET_URL?: string
// Firebase Configuration
FIREBASE_API_KEY?: string
FIREBASE_AUTH_DOMAIN?: string
FIREBASE_PROJECT_ID?: string
FIREBASE_MESSAGING_SENDER_ID?: string
FIREBASE_APP_ID?: string
FIREBASE_STORAGE_BUCKET?: string
FIREBASE_MEASUREMENT_ID?: string
FIREBASE_VAPID_KEY?: string
// Razorpay Checkout
// Google Fonts API
GOOGLE_FONTS_API_KEY?: string
// Node Environment
NODE_ENV?: string
HOST?: string
// Explore Configuration
DEMO_WIDGET_ID?: string
VITE_SHOPIFY_API_KEY?: string
GTM_ID?: string
}
}
}
export {}
+99
View File
@@ -0,0 +1,99 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export interface KnowledgeUploadResponse {
message?: string
error?: string
knowledge?: Array<{
id: number
name: string
type: string
status: string
}>
}
export interface KnowledgePage {
subpage: string
created_at: string | null
updated_at: string | null
}
// An agent a knowledge source is linked to, as reported by the source list.
export interface KnowledgeLinkedAgent {
id: string
name: string
}
export interface KnowledgeItem {
id: number
name: string
type: string
pages: KnowledgePage[]
agents?: KnowledgeLinkedAgent[]
error?: string
}
export interface PaginatedKnowledgeResponse {
knowledge: KnowledgeItem[]
pagination: {
page: number
page_size: number
total_count: number
total_pages: number
}
}
export interface QueueItem {
id: number
source: string
source_type: string
status: 'pending' | 'processing' | 'failed' | 'completed'
error?: string
/** For a failed item: one plain sentence about what went wrong */
reason?: string | null
created_at: string
updated_at?: string
processing_stage?: string
progress_percentage?: number
}
export interface KnowledgeContentChunk {
id: string
content: string
metadata?: Record<string, any>
created_at?: string
}
export interface KnowledgeContent {
knowledge_id: number
source: string
source_type: string
chunks: KnowledgeContentChunk[]
}
// A crawled sub-page, reconstructed on the client by grouping the content chunks
// of a source by their base id (the page URL/name, minus any `_N` chunk suffix).
export interface KnowledgeSubPage {
page_id: string
url: string
title: string
content: string
word_count: number
chunk_count: number
updated_at: string | null
chunk_ids: string[]
}
+41
View File
@@ -0,0 +1,41 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export type LeadAssignmentMode = 'none' | 'sales_team' | 'specific_person' | 'round_robin'
export type CrmSyncTarget = 'none' | 'hubspot' | 'pipedrive' | 'salesforce'
export interface LeadField {
key: string
standard: boolean
enabled: boolean
required: boolean
label?: string | null
options?: string[] | null
}
export interface LeadCaptureConfig {
agent_id: string
enabled: boolean
require_consent: boolean
guidance?: string | null
fields: LeadField[]
assignment_mode: LeadAssignmentMode
assignment_target_user_id?: string | null
crm_sync_target: CrmSyncTarget
slack_notify_enabled: boolean
}
export type LeadCaptureConfigUpdate = Omit<LeadCaptureConfig, 'agent_id'>
+110
View File
@@ -0,0 +1,110 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export type MCPTransportType = 'stdio' | 'sse' | 'http'
export interface MCPTool {
id: number
name: string
description?: string
/** Prompt text: what this source holds and how to query it. */
usage_guidance?: string | null
transport_type: MCPTransportType
enabled: boolean
// STDIO transport fields
command?: string
args?: string[]
env_vars?: Record<string, string>
// SSE/HTTP transport fields
url?: string
headers?: Record<string, string>
timeout?: number
sse_read_timeout?: number
terminate_on_close?: boolean
organization_id: string
created_at: string
updated_at: string
}
export interface MCPToolCreate {
name: string
description?: string
/** Prompt text: what this source holds and how to query it. */
usage_guidance?: string | null
transport_type: MCPTransportType
enabled: boolean
// STDIO transport fields
command?: string
args?: string[]
env_vars?: Record<string, string>
// SSE/HTTP transport fields
url?: string
headers?: Record<string, string>
timeout?: number
sse_read_timeout?: number
terminate_on_close?: boolean
}
export interface MCPToolUpdate {
name?: string
description?: string
/** Prompt text: what this source holds and how to query it. */
usage_guidance?: string | null
enabled?: boolean
// STDIO transport fields
command?: string
args?: string[]
env_vars?: Record<string, string>
// SSE/HTTP transport fields
url?: string
headers?: Record<string, string>
timeout?: number
sse_read_timeout?: number
terminate_on_close?: boolean
}
export interface MCPToolToAgent {
id: number
mcp_tool_id: number
agent_id: string
created_at: string
mcp_tool: MCPTool
}
export interface AgentMCPTools {
id: string
name: string
mcp_tools: MCPTool[]
}
/** What currently points at a connector — shown before deleting it. */
export interface MCPToolReferences {
agents: string[]
used_in_investigations: boolean
}
export interface MCPToolTestResult {
success: boolean
functions: string[]
error: string | null
}
+71
View File
@@ -0,0 +1,71 @@
/*
Copyright 2024-2026 ChatterMate
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 type { User } from "@/types/user"
export interface BusinessHours {
start: string
end: string
enabled: boolean
}
export interface BusinessHoursDict {
monday: BusinessHours
tuesday: BusinessHours
wednesday: BusinessHours
thursday: BusinessHours
friday: BusinessHours
saturday: BusinessHours
sunday: BusinessHours
}
export interface Organization {
id: string
name: string
domain: string
timezone: string
business_hours: BusinessHoursDict
settings: Record<string, any>
is_active: boolean
}
export interface OrganizationCreate {
name: string
domain: string
timezone: string
business_hours: BusinessHoursDict
admin_email: string
admin_name: string
admin_password: string
settings?: Record<string, any>
}
export interface OrganizationUpdate {
name?: string
domain?: string
timezone?: string
business_hours?: BusinessHoursDict
settings?: Record<string, any>
}
export interface OrganizationResponse extends Omit<OrganizationCreate, 'admin_password'> {
id: string
is_active: boolean
access_token?: string
refresh_token?: string
token_type?: string
user: User
}
+80
View File
@@ -0,0 +1,80 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export type LeadStage = 'visitor' | 'lead' | 'customer'
export interface PersonListItem {
id: string
name?: string | null
email?: string | null
phone?: string | null
is_anonymous: boolean
lead_stage: LeadStage
qualified: boolean
source?: Record<string, any> | null
captured_at?: string | null
last_activity?: string | null
synced: boolean
}
export interface PeopleListResponse {
items: PersonListItem[]
total: number
page: number
page_size: number
}
export interface PeopleStats {
total_people: number
/** Engaged-but-unidentified sessions — the count behind the Anonymous tab */
anonymous: number
new_leads_7d: number
customers: number
synced_to_crm: number
}
export interface TimelineEntry {
stage: string
at: string
}
export interface PersonConversation {
session_id: string
agent_name?: string | null
status?: string | null
last_message?: string | null
created_at?: string | null
}
export interface PersonDetail {
id: string
name?: string | null
email?: string | null
phone?: string | null
/** Real email, phone, or qualifying capture — gates person actions */
identified: boolean
is_anonymous: boolean
lead_stage: LeadStage
qualified: boolean
source?: Record<string, any> | null
created_at?: string | null
lead_qualified_at?: string | null
meta_data?: Record<string, any> | null
summary?: string | null
captured_attributes: Record<string, any>
timeline: TimelineEntry[]
conversations: PersonConversation[]
}
+394
View File
@@ -0,0 +1,394 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export type TicketStatus =
| 'open'
| 'triaging'
| 'investigating'
| 'awaiting_approval'
| 'in_progress'
| 'resolved_pending_confirmation'
| 'resolved'
| 'closed'
| 'reopened'
export type TicketPriority = 'urgent' | 'high' | 'medium' | 'low'
export type TicketAiState = 'investigating' | 'awaiting' | 'human' | 'resolved'
export type ResolutionOutcome =
| 'fixed'
| 'workaround'
| 'not_a_bug'
| 'duplicate'
| 'cannot_reproduce'
| 'customer_unresponsive'
| 'escalated_external'
export interface TicketListItem {
id: string
ticket_number: number
display_number: string
title: string
status: TicketStatus
priority: TicketPriority
tags?: string[] | null
assignee_user_id?: string | null
assignee_name?: string | null
ai_state?: TicketAiState | null
sla_due_at?: string | null
resolved_at?: string | null
created_at?: string | null
updated_at?: string | null
}
export interface Ticket extends TicketListItem {
organization_id: string
customer_id?: string | null
original_title?: string | null
description?: string | null
severity?: number | null
source: 'chat_ai' | 'human_agent' | 'manual' | 'api'
intent?: string | null
triage_confidence?: number | null
ai_summary?: string | null
group_id?: string | null
agent_id?: string | null
duplicate_of_ticket_id?: string | null
resolution_outcome?: ResolutionOutcome | null
resolution_summary?: string | null
customer_resolution_message?: string | null
first_response_at?: string | null
closed_at?: string | null
confirmation_requested_at?: string | null
reopened_count: number
csat_requested_at?: string | null
csat_score?: number | null
csat_responded_at?: string | null
external_ref_type?: string | null
external_ref_id?: string | null
external_ref_url?: string | null
created_by_user_id?: string | null
assignee?: { id: string; full_name?: string | null; email?: string | null } | null
customer?: { id: string; full_name?: string | null; email?: string | null } | null
}
export type TicketActivityType =
| 'comment'
| 'status_change'
| 'assignment'
| 'priority_change'
| 'ai_triage'
| 'ai_investigation_started'
| 'ai_investigation_completed'
| 'ai_resolution_proposed'
| 'ai_resolution_approved'
| 'ai_resolution_rejected'
| 'customer_notified'
| 'customer_replied'
| 'customer_linked'
| 'csat_requested'
| 'csat_received'
| 'reopened'
| 'jira_escalated'
export interface TicketActivity {
id: string
activity_type: TicketActivityType
actor_type: 'user' | 'ai' | 'customer' | 'system'
actor_user_id?: string | null
actor_name?: string | null
body?: string | null
is_internal: boolean
activity_metadata?: Record<string, any> | null
created_at?: string | null
}
export interface InvestigationRun {
id: string
run_type: 'triage' | 'investigation'
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'budget_exceeded'
trigger: string
error?: string | null
tool_calls_used: number
max_tool_calls?: number
llm_calls?: number
input_tokens?: number
output_tokens?: number
metered?: boolean
model_name?: string | null
connector_status?: {
configured: number
loaded: number
failed: { name: string; error: string }[]
/** Hard provider rejections seen during the run. */
provider_errors?: string[]
/** Tool calls attempted, and how many of them errored. */
tool_calls?: number
tool_calls_failed?: number
} | null
started_at?: string | null
finished_at?: string | null
created_at?: string | null
}
export type HypothesisStatus = 'pending' | 'testing' | 'validated' | 'invalidated' | 'inconclusive'
export interface InvestigationHypothesis {
id: string
idx: number
title: string
rationale?: string | null
status: HypothesisStatus
confidence?: number | null
conclusion?: string | null
created_at?: string | null
updated_at?: string | null
}
export interface InvestigationEvent {
id: string
hypothesis_id?: string | null
seq: number
event_type: 'phase' | 'tool_call'
label?: string | null
tool_name?: string | null
connector_name?: string | null
tool_input?: string | null
tool_result?: string | null
duration_ms?: number | null
error?: string | null
created_at?: string | null
}
export interface RcaTimelineEntry {
time: string
event: string
}
export interface RcaDocument {
id: string
run_id?: string | null
version: number
summary?: string | null
impact?: string | null
timeline?: RcaTimelineEntry[] | null
investigation_log?: string | null
contributing_factors?: string[] | null
conclusion?: string | null
remediation?: string | null
prevention?: string | null
customer_summary?: string | null
confidence?: number | null
is_partial: boolean
generated_by: string
reviewed_by_user_id?: string | null
reviewed_by_name?: string | null
reviewed_at?: string | null
created_at?: string | null
updated_at?: string | null
}
export type ProposalStatus = 'pending' | 'approved' | 'rejected' | 'superseded'
export interface TicketProposal {
id: string
run_id?: string | null
summary: string
customer_message?: string | null
confidence?: number | null
status: ProposalStatus
decided_by_user_id?: string | null
decided_by_name?: string | null
decided_at?: string | null
reject_reason?: string | null
created_at?: string | null
}
/** Glass-box payload for the ticket detail page. */
export interface InvestigationDetail {
run: InvestigationRun | null
hypotheses: InvestigationHypothesis[]
events: InvestigationEvent[]
rca: RcaDocument | null
proposal: TicketProposal | null
}
export interface DbConnectorColumn {
name: string
type: string
}
export interface DbConnectorTable {
schema: string
table: string
columns: DbConnectorColumn[]
}
export interface DbConnector {
id: string
name: string
engine: 'postgresql' | 'mysql'
host: string
port: number
database: string
username: string
enabled: boolean
allowed_tables?: string[] | null
masked_columns?: string[] | null
// { "schema.table": "customer_column" } — listed tables return only the
// ticket customer's own rows.
row_scope?: Record<string, string> | null
row_scope_key?: 'email' | 'phone'
max_rows: number
statement_timeout_ms: number
ssh_enabled: boolean
ssh_host?: string | null
ssh_port: number
ssh_username?: string | null
last_test_at?: string | null
last_test_ok?: boolean | null
created_at?: string | null
}
export interface DbConnectorPayload {
name: string
engine: 'postgresql' | 'mysql'
host: string
port: number
database: string
username: string
password?: string
enabled?: boolean
allowed_tables?: string[]
masked_columns?: string[]
row_scope?: Record<string, string>
row_scope_key?: 'email' | 'phone'
max_rows?: number
statement_timeout_ms?: number
// SSH tunnel (bastion/jump host) — secrets are write-only.
ssh_enabled?: boolean
ssh_host?: string
ssh_port?: number
ssh_username?: string
ssh_password?: string
ssh_private_key?: string
ssh_private_key_passphrase?: string
}
export interface TicketDetail {
ticket: Ticket
activities: TicketActivity[]
runs: InvestigationRun[]
linked_session_ids: string[]
possible_duplicates?: TicketListItem[]
/** Linked conversation or customer email exists — gates "send to customer" UI. */
can_notify_customer?: boolean
}
export interface Pagination {
total: number
page: number
page_size: number
total_pages: number
}
export interface TicketListResponse {
tickets: TicketListItem[]
pagination: Pagination
}
export interface TicketStats {
open: number
awaiting_approval: number
sla_breaching: number
ai_resolved_pct_7d?: number | null
csat_avg?: number | null
csat_ai_avg?: number | null
csat_human_avg?: number | null
csat_responses: number
csat_window_days: number
}
export interface TicketListFilters {
status: string
priority: string
assignee: string
ai: string
search: string
sort: string
}
export interface TicketCreatePayload {
title: string
description?: string
priority?: TicketPriority
severity?: number
tags?: string[]
customer_id?: string
/** Manual tickets: find-or-create the customer by email for direct-email notifications. */
customer_email?: string
customer_name?: string
session_id?: string
assignee_user_id?: string
group_id?: string
}
export interface TicketUpdatePayload {
title?: string
description?: string
/** Set/replace the ticket's customer by email (find-or-create in the org). */
customer_email?: string
customer_name?: string
status?: TicketStatus
priority?: TicketPriority
severity?: number
tags?: string[]
assignee_user_id?: string | null
group_id?: string | null
resolution_outcome?: ResolutionOutcome
resolution_summary?: string
customer_resolution_message?: string
}
export interface SlaTarget {
first_response_minutes: number
resolution_minutes: number
}
export interface TicketSettings {
autonomy_level: number
auto_investigate_on_create: boolean
min_confidence_to_auto_resolve: number
confirmation_timeout_hours: number
csat_enabled: boolean
sla_targets?: Record<TicketPriority, SlaTarget> | null
created_template?: string | null
resolved_template?: string | null
jira_escalation_enabled: boolean
jira_escalation_priority?: TicketPriority | null
investigation_mcp_tool_ids?: number[] | null
alert_webhook_enabled: boolean
alert_webhook_secret?: string | null
max_tool_calls_per_run: number
max_runs_per_ticket: number
}
export interface TicketUpdateEvent {
ticket_id: string
kind: 'created' | 'status' | 'triage' | 'comment' | 'run' | 'updated'
payload: Record<string, any>
}
+28
View File
@@ -0,0 +1,28 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
// timezone-select-js ships no types; only what we use.
declare module 'timezone-select-js' {
export interface TimezoneEntry {
value: string
label: string
included?: string[]
offset?: string
offsetValue?: number
}
export function listTz(): TimezoneEntry[]
export function clientTz(): string
}
+65
View File
@@ -0,0 +1,65 @@
/*
Copyright 2024-2026 ChatterMate
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 type { Permission } from "@/services/roles"
export interface Role {
id: string
name: string
description?: string
permissions?: Permission[]
created_at?: string
updated_at?: string
is_default?: boolean
}
/**
* The two chat-scope toggles on the user form.
*
* Not properties of a user — permissions live on roles. These describe the
* chat scope a person should have and the API resolves them to a role,
* reusing an existing one where it can. Omitted means "whatever the chosen
* role already grants".
*/
export interface ChatScopeFields {
see_all_ai_chats?: boolean
see_all_org_chats?: boolean
}
export interface UserGroup {
id: string
name: string
description?: string
organization_id: string
users?: User[]
created_at?: string
updated_at?: string
}
export interface User {
id: string
email: string
full_name: string
organization_id: string
is_active?: boolean
groups?: UserGroup[]
role?: Role
created_at?: string
updated_at?: string
profile_pic?: string
is_online?: boolean
last_seen?: string
}
+46
View File
@@ -0,0 +1,46 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export interface WidgetApp {
id: string
name: string
description?: string
organization_id: string
created_by: string
is_active: boolean
created_at: string
updated_at: string
}
export interface WidgetAppWithKey extends WidgetApp {
api_key: string // Only returned on create/regenerate
}
export interface WidgetAppCreate {
name: string
description?: string
}
export interface WidgetAppUpdate {
name?: string
description?: string
is_active?: boolean
}
export interface WidgetAppListResponse {
total: number
apps: WidgetApp[]
}
+112
View File
@@ -0,0 +1,112 @@
/*
Copyright 2024-2026 ChatterMate
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 type { ChatStyle, WidgetPosition } from './agent'
export interface Widget {
id: string
name: string
organization_id: string
agent_id: string
}
export interface WidgetCreate {
name: string
agent_id: string
}
export interface HumanAgent {
human_agent_name?: string
human_agent_profile_pic?: string
}
export interface AgentCustomization {
id?: number;
agent_id?: string;
photo_url?: string;
chat_background_color?: string;
chat_bubble_color?: string;
chat_text_color?: string;
icon_url?: string;
icon_color?: string;
accent_color?: string;
font_family?: string;
custom_css?: string;
customization_metadata?: Record<string, any>;
chat_style?: ChatStyle;
widget_position?: WidgetPosition;
welcome_title?: string;
welcome_subtitle?: string;
welcome_message?: string;
chat_initiation_messages?: string[];
quick_actions?: string[];
}
export interface SocketError {
type: 'connection_error' | 'auth_error' | 'chat_error' | 'ai_config_missing' | 'customer_required';
error?: string;
}
// Helper functions
export const isColorDark = (color: string): boolean => {
const hex = color.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return brightness < 128
}
export const adjustColorBrightness = (color: string, amount: number): string => {
const hex = color.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
const isBackgroundDark = isColorDark(color)
const newR = isBackgroundDark ? Math.min(255, r + amount) : Math.max(0, r - amount)
const newG = isBackgroundDark ? Math.min(255, g + amount) : Math.max(0, g - amount)
const newB = isBackgroundDark ? Math.min(255, b + amount) : Math.max(0, b - amount)
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`
}
export const formatTime = (date: Date): string => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export const getErrorMessage = (error: SocketError): string => {
switch(error.type) {
case 'connection_error':
return 'Unable to connect. Please try again later.'
case 'auth_error':
return 'Authentication failed. Please refresh the page.'
case 'chat_error':
return 'Unable to send message. Please try again.'
case 'ai_config_missing':
return 'Chat service is currently unavailable.'
default:
return error.error || 'Something went wrong. Please try again.'
}
}
+30
View File
@@ -0,0 +1,30 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
interface Window {
__INITIAL_DATA__?: {
widgetId: string;
agentName: string;
customization: Record<string, any>;
customerId: string;
customer: Record<string, any>;
initialToken?: string;
workflow?: boolean;
allowAttachments?: boolean;
/** How soon the header may claim someone replies. */
presence?: import('../utils/widgetPresence').WidgetPresence;
};
}
+76
View File
@@ -0,0 +1,76 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export enum WorkflowStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived'
}
export enum ExitCondition {
SINGLE_EXECUTION = 'single_execution',
CONTINUOUS_EXECUTION = 'continuous_execution'
}
export interface WorkflowCreate {
name: string
description?: string
agent_id: string
}
export interface WorkflowResponse {
id: string
name: string
description?: string
agent_id: string
organization_id: string
status: WorkflowStatus
created_at: string
updated_at: string
}
export interface WorkflowNode {
id: string
workflow_id: string
node_type: string
name: string
description?: string
position_x: number
position_y: number
config: Record<string, any>
created_at: string
updated_at: string
}
export interface WorkflowConnection {
id: string
workflow_id: string
source_node_id: string
target_node_id: string
source_handle?: string
target_handle?: string
label?: string
condition?: string
priority: number
connection_metadata: Record<string, any>
created_at: string
updated_at: string
}
export interface WorkflowNodesResponse {
nodes: WorkflowNode[]
connections: WorkflowConnection[]
}
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
/** One validation problem in FastAPI's 422 body. */
interface ValidationDetail {
msg?: string
}
/**
* A sentence to show for a failed API call. FastAPI sends `detail` as a
* string for errors we raise, but as a list of objects for request
* validation (422) - shown raw, that list reads "[object Object]".
*/
export function apiErrorMessage(error: any, fallback: string): string {
const data = error?.response?.data
const detail = data?.detail
if (typeof detail === 'string' && detail) return detail
if (Array.isArray(detail)) {
const msg = (detail as ValidationDetail[]).find((d) => typeof d?.msg === 'string')?.msg
// Pydantic prefixes a validator's own message with "Value error, "
if (msg) return msg.replace(/^Value error, /, '')
}
if (typeof data?.message === 'string' && data.message) return data.message
return fallback
}
+62
View File
@@ -0,0 +1,62 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
/**
* True when `path` is already an absolute URL (e.g. a signed S3/CDN link) and
* should be used as-is instead of being prefixed with the API base URL.
*
* We test for an absolute http(s) scheme rather than matching a host substring
* such as "amazonaws.com": substring host checks are unreliable — the host can
* appear anywhere in an attacker-influenced string — and are flagged by static
* analysis (CodeQL js/incomplete-url-substring-sanitization).
*/
export const isAbsoluteUrl = (path?: string | null): boolean =>
!!path && (/^https?:\/\//i.test(path) || path.startsWith('data:'))
/**
* Build a browser-usable URL for a stored upload path.
*
* `store_upload` returns local-storage paths that already carry the API prefix
* (`/api/v1/uploads/...`, matching the static mount), so the API base must be
* reduced to its origin before concatenating — otherwise the prefix is emitted
* twice and the request 404s. Absolute URLs (signed S3/CDN links, data URIs,
* blob previews) are returned untouched.
*
* `apiBase` is passed in rather than resolved here because the dashboard and
* the embeddable widget read their API URL from different resolvers with
* different fallbacks; see getApiUrl() and widgetEnv.API_URL.
*/
export const buildUploadUrl = (stored: string | null | undefined, apiBase: string): string => {
if (!stored) return ''
if (isAbsoluteUrl(stored) || stored.startsWith('blob:')) return stored
const origin = apiBase.replace(/\/api\/v1\/?$/, '')
return `${origin}${stored.startsWith('/') ? '' : '/'}${stored}`
}
/**
* Browser URL for a chat attachment in the dashboard.
*
* The API hands attachments out signed — an S3 presigned URL, or a
* `/api/v1/files/local/...?exp=&sig=` link for local storage — and those
* resolve like any other upload. A bare `/uploads/chat_attachments/...` path
* is not publicly served, so it goes through the authenticated download
* endpoint instead.
*/
export const buildAttachmentUrl = (fileUrl: string | null | undefined, apiBase: string): string => {
const unsigned = fileUrl?.match(/^\/?uploads\/(.+)$/)
if (unsigned) return `${apiBase.replace(/\/+$/, '')}/files/download/${unsigned[1]}`
return buildUploadUrl(fileUrl, apiBase)
}
+54
View File
@@ -0,0 +1,54 @@
/*
Copyright 2024-2026 ChatterMate
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.
*/
export interface Rgb {
r: number
g: number
b: number
}
export function hexToRgb(hex: string): Rgb | null {
let value = hex.trim().replace(/^#/, '')
if (value.length === 3) {
value = value
.split('')
.map((ch) => ch + ch)
.join('')
}
if (!/^[0-9a-fA-F]{6}$/.test(value)) return null
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
}
}
/** WCAG relative luminance (0 = black, 1 = white). */
export function relativeLuminance({ r, g, b }: Rgb): number {
const linear = (channel: number) => {
const c = channel / 255
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
/** Dark or light ink over an arbitrary brand color (mirrors the backend's
* contrast_ink so admin preview and public page always agree). */
export function contrastInk(hex: string): '#12131A' | '#FFFFFF' {
const rgb = hexToRgb(hex)
if (!rgb) return '#FFFFFF'
return relativeLuminance(rgb) > 0.45 ? '#12131A' : '#FFFFFF'
}
+26
View File
@@ -0,0 +1,26 @@
/*
Copyright 2024-2026 ChatterMate
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 mitt from 'mitt'
type Events = {
'socket:reconnected': void
'knowledge-updated': void
// Add other events here as needed
}
const emitter = mitt<Events>()
export default emitter
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more