feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
9eb8613c05
commit
556b650c7a
123 files changed
+25293
No files matched your search
@@ -0,0 +1,724 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from urllib.parse import quote, urlparse
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.models.schemas.organization import (
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
OrganizationResponse,
|
||||
OrganizationCreateResponse
|
||||
)
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.services.auth_sessions import issue_tokens
|
||||
from app.core.logger import get_logger
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, DEFAULT_AGENT_ROLE_PERMISSIONS
|
||||
from app.repositories.organization import OrganizationRepository
|
||||
from app.models.agent import Agent
|
||||
from app.models.session_to_agent import SessionToAgent
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
from app.core.cors import update_cors_middleware
|
||||
from app.core.application import app # Import the FastAPI app instance from the new location
|
||||
|
||||
# Disposable-address rejection lives in the enterprise module: the hosted signup
|
||||
# flow is what attracts throwaway signups, and the community edition has no
|
||||
# reason to carry an 8k-domain blocklist. Absent, every address is accepted.
|
||||
try:
|
||||
from app.enterprise.services.email_validation import ensure_not_disposable
|
||||
|
||||
HAS_EMAIL_VALIDATION = True
|
||||
except ImportError:
|
||||
HAS_EMAIL_VALIDATION = False
|
||||
|
||||
DOMAIN_TAKEN_MESSAGE = "That domain is already used by another organization."
|
||||
LOCALHOST_DOMAIN_MESSAGE = "Localhost can't be used as your domain. Use the domain of the site where you'll add the widget."
|
||||
LOCALHOST_HOSTS = frozenset({"localhost", "127.0.0.1"})
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(
|
||||
tags=["organizations"]
|
||||
)
|
||||
|
||||
|
||||
def is_localhost_domain(domain: str) -> bool:
|
||||
"""Whether a domain (bare host, host:port or URL) points at localhost"""
|
||||
domain = domain.strip()
|
||||
try:
|
||||
host = urlparse(domain if "://" in domain else f"//{domain}").hostname
|
||||
except ValueError:
|
||||
return False
|
||||
return (host or "") in LOCALHOST_HOSTS
|
||||
|
||||
|
||||
@router.post("", response_model=OrganizationCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_organization(
|
||||
org_data: OrganizationCreate,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new organization with an admin user and default roles"""
|
||||
try:
|
||||
if HAS_EMAIL_VALIDATION:
|
||||
ensure_not_disposable(org_data.admin_email)
|
||||
|
||||
# Check if any organization exists
|
||||
existing_orgs = db.query(Organization).first()
|
||||
|
||||
# If organizations exist, return 403 Forbidden
|
||||
if existing_orgs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Organization already exists"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Create organization
|
||||
org_repo = OrganizationRepository(db)
|
||||
organization = org_repo.create_organization(
|
||||
name=org_data.name,
|
||||
domain=org_data.domain,
|
||||
timezone=org_data.timezone,
|
||||
business_hours=org_data.business_hours
|
||||
)
|
||||
|
||||
# Note: no default agent is created here. New orgs start with zero
|
||||
# agents so the guided onboarding flow (Create → Teach → Test → Launch)
|
||||
# is what creates the first agent.
|
||||
|
||||
# Get or create default permissions
|
||||
permissions = {}
|
||||
for name, description in Permission.default_permissions():
|
||||
# Try to get existing permission
|
||||
perm = db.query(Permission).filter(Permission.name == name).first()
|
||||
if not perm:
|
||||
perm = Permission(name=name, description=description)
|
||||
db.add(perm)
|
||||
db.flush()
|
||||
permissions[name] = perm
|
||||
|
||||
# Create default roles. Exactly one must be is_default: nothing enforces
|
||||
# that, and get_default_role() just takes .first() with no ordering, so
|
||||
# two defaults means a newly invited user lands on whichever row the
|
||||
# database happens to yield. It has to be Agent — the org creator is
|
||||
# given Admin explicitly below.
|
||||
admin_role = Role(
|
||||
name="Admin",
|
||||
description="Full access to all features",
|
||||
organization_id=organization.id,
|
||||
is_default=False
|
||||
)
|
||||
admin_role.permissions = list(permissions.values()) # All permissions
|
||||
db.add(admin_role)
|
||||
|
||||
agent_role = Role(
|
||||
name="Agent",
|
||||
description="Access to assigned chats and the unclaimed AI queue",
|
||||
organization_id=organization.id,
|
||||
is_default=True
|
||||
)
|
||||
agent_role.permissions = [
|
||||
permissions[name] for name in DEFAULT_AGENT_ROLE_PERMISSIONS
|
||||
]
|
||||
db.add(agent_role)
|
||||
db.flush()
|
||||
|
||||
# Create admin user with admin role
|
||||
admin = User(
|
||||
email=org_data.admin_email,
|
||||
full_name=org_data.admin_name,
|
||||
hashed_password=User.get_password_hash(org_data.admin_password),
|
||||
organization_id=organization.id,
|
||||
role_id=admin_role.id,
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
# Open the admin's first session and set cookies
|
||||
access_token, refresh_token = issue_tokens(db, admin.id, organization.id)
|
||||
|
||||
# Set cookies and return response
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
|
||||
max_age=1800 # 30 minutes
|
||||
)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
|
||||
max_age=604800 # 7 days
|
||||
)
|
||||
|
||||
# Set session data with role information
|
||||
response.set_cookie(
|
||||
key="user_info",
|
||||
value=quote(json.dumps({
|
||||
"id": str(admin.id),
|
||||
"email": admin.email,
|
||||
"full_name": admin.full_name,
|
||||
"organization_id": str(organization.id),
|
||||
"role": admin_role.to_dict()
|
||||
}, default=str)),
|
||||
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
|
||||
secure=True, # Required when samesite="none"
|
||||
max_age=604800 # 7 days
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Update CORS origins after creating organization
|
||||
update_cors_middleware(app)
|
||||
|
||||
return {
|
||||
"id": organization.id,
|
||||
"name": organization.name,
|
||||
"domain": organization.domain,
|
||||
"timezone": organization.timezone,
|
||||
"business_hours": organization.business_hours,
|
||||
"settings": organization.settings,
|
||||
"is_active": organization.is_active,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"id": admin.id,
|
||||
"email": admin.email,
|
||||
"full_name": admin.full_name,
|
||||
"organization_id": organization.id,
|
||||
"role": admin_role.to_dict()
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Organization creation failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create organization: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup-status", response_model=dict)
|
||||
async def is_organization_setup(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if at least one active organization is set up."""
|
||||
try:
|
||||
active_org_exists = db.query(Organization.id)\
|
||||
.filter(Organization.is_active == True)\
|
||||
.first() is not None
|
||||
|
||||
return {"is_setup": active_org_exists}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check organization setup status: {str(e)}", exc_info=True)
|
||||
# In case of error, conservatively assume setup might be needed or report error
|
||||
# Returning False might lock users out if DB is temporarily unavailable
|
||||
# A 500 error might be better to indicate a server issue
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to check organization setup status"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/check-domain/{domain}")
|
||||
async def check_domain_availability(
|
||||
domain: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if an organization domain is available"""
|
||||
try:
|
||||
existing_org = db.query(Organization).filter(Organization.domain == domain).first()
|
||||
return {
|
||||
"available": not existing_org,
|
||||
"message": "Domain is available" if not existing_org else "Domain already exists"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check domain availability: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to check domain availability"
|
||||
)
|
||||
|
||||
|
||||
def get_own_organization_or_404(db: Session, org_id: UUID, current_user: User) -> Organization:
|
||||
"""Load an organization the caller actually belongs to.
|
||||
|
||||
These routes take the org id from the path, so without this a user of one
|
||||
tenant could read — and with manage_organization, rewrite — another
|
||||
tenant's record. 404 rather than 403: the existence of another
|
||||
organization isn't the caller's business either.
|
||||
"""
|
||||
if current_user.organization_id != org_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Organization not found"
|
||||
)
|
||||
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Organization not found"
|
||||
)
|
||||
return org
|
||||
|
||||
|
||||
@router.get("/{org_id}", response_model=OrganizationResponse)
|
||||
async def get_organization(
|
||||
org_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get organization by ID"""
|
||||
try:
|
||||
return get_own_organization_or_404(db, org_id, current_user)
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get organization {
|
||||
org_id}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve organization. Please try again later."
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{org_id}", response_model=OrganizationResponse)
|
||||
async def update_organization(
|
||||
org_id: UUID,
|
||||
org_data: OrganizationUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update organization details including business hours"""
|
||||
try:
|
||||
org = get_own_organization_or_404(db, org_id, current_user)
|
||||
|
||||
# Update only provided fields
|
||||
update_data = org_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate business hours if provided
|
||||
if 'business_hours' in update_data:
|
||||
business_hours = update_data['business_hours']
|
||||
required_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||
required_fields = ['start', 'end', 'enabled']
|
||||
|
||||
# Validate all days are present
|
||||
if not all(day in business_hours for day in required_days):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Business hours must include all days of the week"
|
||||
)
|
||||
|
||||
# Validate each day has required fields
|
||||
for day in required_days:
|
||||
if not all(field in business_hours[day] for field in required_fields):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Business hours for {day} must include start, end, and enabled status"
|
||||
)
|
||||
|
||||
# Validate time format (HH:MM)
|
||||
start = business_hours[day]['start']
|
||||
end = business_hours[day]['end']
|
||||
try:
|
||||
hours, minutes = start.split(':')
|
||||
if not (0 <= int(hours) <= 23 and 0 <= int(minutes) <= 59):
|
||||
raise ValueError
|
||||
hours, minutes = end.split(':')
|
||||
if not (0 <= int(hours) <= 23 and 0 <= int(minutes) <= 59):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid time format for {day}. Use HH:MM format (24-hour)"
|
||||
)
|
||||
|
||||
new_domain = update_data.get('domain')
|
||||
if new_domain and new_domain.lower() != org.domain.lower() and is_localhost_domain(new_domain):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=LOCALHOST_DOMAIN_MESSAGE
|
||||
)
|
||||
if new_domain and OrganizationRepository(db).is_domain_taken(new_domain, exclude_org_id=org.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=DOMAIN_TAKEN_MESSAGE
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(org, field, value)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another org claimed the domain between the check and the write
|
||||
if 'domain' not in update_data:
|
||||
raise
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=DOMAIN_TAKEN_MESSAGE
|
||||
)
|
||||
db.refresh(org)
|
||||
|
||||
# Update CORS origins after updating organization
|
||||
update_cors_middleware(app)
|
||||
|
||||
return org
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update organization {org_id}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update organization. Please try again later."
|
||||
)
|
||||
|
||||
|
||||
# @router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
# async def delete_organization(
|
||||
# org_id: UUID,
|
||||
# current_user: User = Depends(require_permissions("manage_organization")),
|
||||
# db: Session = Depends(get_db)
|
||||
# ):
|
||||
# """
|
||||
# Hard delete organization and all related data.
|
||||
|
||||
# This operation permanently removes:
|
||||
# - Users and their notifications, knowledge queue items, session assignments, ratings
|
||||
# - Customers and their ratings, chat histories
|
||||
# - Agents and their customizations, widgets, knowledge links, tool links, ratings, chat histories
|
||||
# - Workflows and workflow nodes
|
||||
# - Enterprise subscriptions and PayPal orders (if available)
|
||||
# - AI configurations
|
||||
# - Knowledge sources and knowledge-to-agent links
|
||||
# - MCP tools and their agent links
|
||||
# - User groups
|
||||
# - Roles and their permission associations
|
||||
# - Integration data (Shopify shops, Jira tokens)
|
||||
# - All remaining chat histories
|
||||
# - The organization itself
|
||||
|
||||
# WARNING: This operation cannot be undone.
|
||||
# """
|
||||
# try:
|
||||
# # Check if organization exists
|
||||
# org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
# if not org:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_404_NOT_FOUND,
|
||||
# detail="Organization not found"
|
||||
# )
|
||||
|
||||
# # Verify user belongs to this organization
|
||||
# if current_user.organization_id != org_id:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_403_FORBIDDEN,
|
||||
# detail="You can only delete your own organization"
|
||||
# )
|
||||
|
||||
# logger.info(f"Starting hard delete for organization {org_id}")
|
||||
|
||||
# # Import models that might not be available in all environments
|
||||
# try:
|
||||
# from app.enterprise.models.subscription import Subscription
|
||||
# from app.enterprise.models.order import PayPalOrder
|
||||
# enterprise_available = True
|
||||
# except ImportError:
|
||||
# enterprise_available = False
|
||||
# logger.info("Enterprise models not available")
|
||||
|
||||
# # Import all required models
|
||||
# from app.models.user import User, UserGroup
|
||||
# from app.models.role import Role
|
||||
# from app.models.permission import Permission
|
||||
# from app.models.agent import Agent, AgentCustomization
|
||||
# from app.models.widget import Widget
|
||||
# from app.models.ai_config import AIConfig
|
||||
# from app.models.knowledge import Knowledge
|
||||
# from app.models.knowledge_to_agent import KnowledgeToAgent
|
||||
# from app.models.knowledge_queue import KnowledgeQueue
|
||||
# from app.models.chat_history import ChatHistory
|
||||
# from app.models.customer import Customer
|
||||
# from app.models.session_to_agent import SessionToAgent
|
||||
# from app.models.rating import Rating
|
||||
# from app.models.workflow import Workflow
|
||||
# from app.models.workflow_node import WorkflowNode
|
||||
# from app.models.mcp_tool import MCPTool, MCPToolToAgent
|
||||
# from app.models.notification import Notification
|
||||
# from app.models.shopify.shopify_shop import ShopifyShop
|
||||
# from app.models.jira import JiraToken
|
||||
|
||||
# # 1. Delete users and their related data first
|
||||
# logger.info("Deleting users and related data...")
|
||||
# users = db.query(User).filter(User.organization_id == org_id).all()
|
||||
# for user in users:
|
||||
# # Delete user notifications
|
||||
# db.query(Notification).filter(Notification.user_id == user.id).delete()
|
||||
# # Delete user knowledge queue items
|
||||
# db.query(KnowledgeQueue).filter(KnowledgeQueue.user_id == user.id).delete()
|
||||
# # Delete user session assignments
|
||||
# db.query(SessionToAgent).filter(SessionToAgent.user_id == user.id).delete()
|
||||
# # Delete user ratings
|
||||
# db.query(Rating).filter(Rating.user_id == user.id).delete()
|
||||
|
||||
# # Delete users themselves (this will cascade to user_groups via the association table)
|
||||
# db.query(User).filter(User.organization_id == org_id).delete()
|
||||
|
||||
# # 2. Delete customer-related data
|
||||
# logger.info("Deleting customer data...")
|
||||
# customers = db.query(Customer).filter(Customer.organization_id == org_id).all()
|
||||
# for customer in customers:
|
||||
# # Delete customer ratings
|
||||
# db.query(Rating).filter(Rating.customer_id == customer.id).delete()
|
||||
# # Delete customer chat histories
|
||||
# db.query(ChatHistory).filter(ChatHistory.customer_id == customer.id).delete()
|
||||
|
||||
# # Delete customers
|
||||
# db.query(Customer).filter(Customer.organization_id == org_id).delete()
|
||||
|
||||
# # 3. Delete agents and their related data
|
||||
# logger.info("Deleting agents and related data...")
|
||||
# agents = db.query(Agent).filter(Agent.organization_id == org_id).all()
|
||||
# for agent in agents:
|
||||
# # Delete agent customizations
|
||||
# db.query(AgentCustomization).filter(AgentCustomization.agent_id == agent.id).delete()
|
||||
# # Delete widgets for this agent
|
||||
# db.query(Widget).filter(Widget.agent_id == agent.id).delete()
|
||||
# # Delete knowledge-to-agent links
|
||||
# db.query(KnowledgeToAgent).filter(KnowledgeToAgent.agent_id == agent.id).delete()
|
||||
# # Delete MCP tool-to-agent links
|
||||
# db.query(MCPToolToAgent).filter(MCPToolToAgent.agent_id == agent.id).delete()
|
||||
# # Delete agent session assignments
|
||||
# db.query(SessionToAgent).filter(SessionToAgent.agent_id == agent.id).delete()
|
||||
# # Delete agent ratings
|
||||
# db.query(Rating).filter(Rating.agent_id == agent.id).delete()
|
||||
# # Delete agent chat histories
|
||||
# db.query(ChatHistory).filter(ChatHistory.agent_id == agent.id).delete()
|
||||
|
||||
# # Delete agents themselves
|
||||
# db.query(Agent).filter(Agent.organization_id == org_id).delete()
|
||||
|
||||
# # 4. Delete workflows and workflow nodes
|
||||
# logger.info("Deleting workflows...")
|
||||
# workflows = db.query(Workflow).filter(Workflow.organization_id == org_id).all()
|
||||
# for workflow in workflows:
|
||||
# # Delete workflow nodes
|
||||
# db.query(WorkflowNode).filter(WorkflowNode.workflow_id == workflow.id).delete()
|
||||
|
||||
# # Delete workflows
|
||||
# db.query(Workflow).filter(Workflow.organization_id == org_id).delete()
|
||||
|
||||
# # 5. Delete remaining widgets (not associated with agents)
|
||||
# logger.info("Deleting remaining widgets...")
|
||||
# db.query(Widget).filter(Widget.organization_id == org_id).delete()
|
||||
|
||||
# # 6. Delete enterprise subscriptions and orders if available
|
||||
# if enterprise_available:
|
||||
# logger.info("Deleting enterprise subscriptions and orders...")
|
||||
# # Delete PayPal orders first (they reference subscriptions)
|
||||
# db.query(PayPalOrder).filter(PayPalOrder.organization_id == org_id).delete()
|
||||
# # Delete subscriptions
|
||||
# db.query(Subscription).filter(Subscription.organization_id == org_id).delete()
|
||||
|
||||
# # 7. Delete AI configs
|
||||
# logger.info("Deleting AI configs...")
|
||||
# db.query(AIConfig).filter(AIConfig.organization_id == org_id).delete()
|
||||
|
||||
# # 8. Delete knowledge sources and related data
|
||||
# logger.info("Deleting knowledge sources...")
|
||||
# knowledge_sources = db.query(Knowledge).filter(Knowledge.organization_id == org_id).all()
|
||||
# for knowledge in knowledge_sources:
|
||||
# # Delete any remaining knowledge-to-agent links
|
||||
# db.query(KnowledgeToAgent).filter(KnowledgeToAgent.knowledge_id == knowledge.id).delete()
|
||||
|
||||
# # Delete knowledge sources
|
||||
# db.query(Knowledge).filter(Knowledge.organization_id == org_id).delete()
|
||||
|
||||
# # 9. Delete remaining knowledge queue items
|
||||
# logger.info("Deleting knowledge queue items...")
|
||||
# db.query(KnowledgeQueue).filter(KnowledgeQueue.organization_id == org_id).delete()
|
||||
|
||||
# # 10. Delete MCP tools
|
||||
# logger.info("Deleting MCP tools...")
|
||||
# mcp_tools = db.query(MCPTool).filter(MCPTool.organization_id == org_id).all()
|
||||
# for tool in mcp_tools:
|
||||
# # Delete any remaining MCP tool-to-agent links
|
||||
# db.query(MCPToolToAgent).filter(MCPToolToAgent.mcp_tool_id == tool.id).delete()
|
||||
|
||||
# # Delete MCP tools
|
||||
# db.query(MCPTool).filter(MCPTool.organization_id == org_id).delete()
|
||||
|
||||
# # 11. Delete user groups
|
||||
# logger.info("Deleting user groups...")
|
||||
# db.query(UserGroup).filter(UserGroup.organization_id == org_id).delete()
|
||||
|
||||
# # 12. Delete roles and their permission associations
|
||||
# logger.info("Deleting roles and their permissions...")
|
||||
|
||||
# # First, directly delete entries from the role_permissions association table
|
||||
# from app.models.permission import role_permissions
|
||||
|
||||
# # Get all role IDs for this organization
|
||||
# role_ids = [role_id for role_id, in db.query(Role.id).filter(Role.organization_id == org_id).all()]
|
||||
|
||||
# if role_ids:
|
||||
# # Delete from the association table using raw SQL
|
||||
# # This is necessary because SQLAlchemy ORM doesn't directly support bulk deletion from association tables
|
||||
# delete_stmt = role_permissions.delete().where(role_permissions.c.role_id.in_(role_ids))
|
||||
# db.execute(delete_stmt)
|
||||
# db.flush()
|
||||
|
||||
# # Now delete the roles
|
||||
# db.query(Role).filter(Role.organization_id == org_id).delete()
|
||||
|
||||
# # 13. Delete integration-specific data
|
||||
# logger.info("Deleting integration data...")
|
||||
# # Delete Shopify shops
|
||||
# db.query(ShopifyShop).filter(ShopifyShop.organization_id == org_id).delete()
|
||||
# # Delete Jira tokens
|
||||
# db.query(JiraToken).filter(JiraToken.organization_id == org_id).delete()
|
||||
|
||||
# # 14. Delete any remaining chat histories
|
||||
# logger.info("Deleting remaining chat histories...")
|
||||
# db.query(ChatHistory).filter(ChatHistory.organization_id == org_id).delete()
|
||||
|
||||
# # 15. Finally, delete the organization itself
|
||||
# logger.info("Deleting organization...")
|
||||
# db.delete(org)
|
||||
|
||||
# # Commit all changes
|
||||
# db.commit()
|
||||
# logger.info(f"Successfully hard deleted organization {org_id}")
|
||||
|
||||
# # Update CORS origins after deleting organization
|
||||
# update_cors_middleware(app)
|
||||
|
||||
# return None
|
||||
# except HTTPException as he:
|
||||
# db.rollback()
|
||||
# raise he
|
||||
# except Exception as e:
|
||||
# db.rollback()
|
||||
# logger.error(f"Failed to delete organization {org_id}: {str(e)}", exc_info=True)
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
# detail="Failed to delete organization. Please try again later."
|
||||
# )
|
||||
|
||||
|
||||
@router.get("/{org_id}/stats")
|
||||
async def get_organization_stats(
|
||||
org_id: UUID,
|
||||
current_user: User = Depends(require_permissions("view_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get organization statistics"""
|
||||
org = get_own_organization_or_404(db, org_id, current_user)
|
||||
|
||||
# --- Members ---
|
||||
total_users = db.query(User).filter(User.organization_id == org_id).count()
|
||||
active_users = db.query(User).filter(
|
||||
User.organization_id == org_id,
|
||||
User.is_active == True
|
||||
).count()
|
||||
# Count admins consistently with the team-overview endpoint (users.py):
|
||||
# a user is an admin if their role grants manage_organization/super_admin,
|
||||
# or the role is literally named "Admin". A plain Role.name == "Admin"
|
||||
# match misses custom/renamed roles that still hold admin permissions.
|
||||
org_users = db.query(User).filter(User.organization_id == org_id).all()
|
||||
admins = sum(
|
||||
1
|
||||
for u in org_users
|
||||
if u.role and (
|
||||
{"manage_organization", "super_admin"} & {p.name for p in (u.role.permissions or [])}
|
||||
or u.role.name == "Admin"
|
||||
)
|
||||
)
|
||||
members_agents = total_users - admins
|
||||
|
||||
# --- Active now (online) ---
|
||||
active_now = db.query(User).filter(
|
||||
User.organization_id == org_id,
|
||||
User.is_online == True
|
||||
).count()
|
||||
|
||||
# --- AI agents (live vs draft) ---
|
||||
agents_total = db.query(Agent).filter(Agent.organization_id == org_id).count()
|
||||
agents_live = db.query(Agent).filter(
|
||||
Agent.organization_id == org_id,
|
||||
Agent.is_active == True
|
||||
).count()
|
||||
agents_draft = agents_total - agents_live
|
||||
|
||||
# --- Conversations (last 30d vs previous 30d) ---
|
||||
now = datetime.now(timezone.utc)
|
||||
d30 = now - timedelta(days=30)
|
||||
d60 = now - timedelta(days=60)
|
||||
conversations_30d = db.query(SessionToAgent).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.assigned_at >= d30
|
||||
).count()
|
||||
conversations_prev_30d = db.query(SessionToAgent).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.assigned_at >= d60,
|
||||
SessionToAgent.assigned_at < d30
|
||||
).count()
|
||||
if conversations_prev_30d:
|
||||
conversations_change_pct = round(
|
||||
(conversations_30d - conversations_prev_30d) / conversations_prev_30d * 100
|
||||
)
|
||||
else:
|
||||
conversations_change_pct = 100 if conversations_30d else 0
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"settings": org.settings,
|
||||
# Design KPI strip
|
||||
"members_total": total_users,
|
||||
"members_admins": admins,
|
||||
"members_agents": members_agents,
|
||||
"active_now": active_now,
|
||||
"agents_total": agents_total,
|
||||
"agents_live": agents_live,
|
||||
"agents_draft": agents_draft,
|
||||
"conversations_30d": conversations_30d,
|
||||
"conversations_prev_30d": conversations_prev_30d,
|
||||
"conversations_change_pct": conversations_change_pct,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.core.redis import get_redis
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
PEOPLE_READ_PERMISSIONS,
|
||||
PEOPLE_WRITE_PERMISSIONS,
|
||||
get_current_user,
|
||||
has_any_permission,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.repositories.people import PeopleRepository
|
||||
from app.repositories.crm import CrmConnectionRepository, CrmCustomerSyncRepository
|
||||
from app.models.crm import CrmConnectionStatus
|
||||
from app.services.crm_sync import sync_customer_to_crm, CrmManualSyncError
|
||||
from app.models.schemas.people import (
|
||||
PeopleListResponse, PeopleStats, PersonDetail, PersonListItem, PersonUpdateRequest,
|
||||
)
|
||||
from app.models.schemas.crm import CrmCustomerSyncOut, PersonCrmStatus
|
||||
from app.core.params import PageNumber
|
||||
|
||||
# Enterprise gating — People is part of Lead Management, a Pro-plan feature where
|
||||
# the enterprise module is installed; OSS/community deployments are unrestricted.
|
||||
try:
|
||||
from app.enterprise.repositories.plan import PlanRepository
|
||||
from app.enterprise.services.feature_access import require_accessible_subscription
|
||||
HAS_ENTERPRISE = True
|
||||
except ImportError:
|
||||
HAS_ENTERPRISE = False
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Ignore a repeat manual "Sync now" for the same person within this window.
|
||||
MANUAL_SYNC_DEBOUNCE_SECONDS = 10
|
||||
|
||||
def _require_people_access(
|
||||
current_user: User,
|
||||
db: Session,
|
||||
permissions: tuple = PEOPLE_READ_PERMISSIONS
|
||||
) -> None:
|
||||
# Reading the directory is its own grant (view_people): an agent handling a
|
||||
# conversation should be able to look up who they are talking to without
|
||||
# also gaining the right to read every other agent's chats. The org-wide
|
||||
# inbox permissions imply it, so admins are unaffected. Writes pass
|
||||
# PEOPLE_WRITE_PERMISSIONS instead. has_any_permission honours the
|
||||
# super_admin bypass this check used to miss (a super_admin could send
|
||||
# templates but got 403 here).
|
||||
if not has_any_permission(current_user, permissions):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
|
||||
# Pro-plan gate (Lead Management) where the enterprise module is present.
|
||||
if HAS_ENTERPRISE:
|
||||
subscription = require_accessible_subscription(db, current_user.organization_id)
|
||||
if not PlanRepository(db).check_feature_availability(str(subscription.plan_id), 'lead_capture'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="People / Lead Management is not available in your current plan. Please upgrade to Pro.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PeopleListResponse)
|
||||
async def list_people(
|
||||
stage: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
page: PageNumber = 1,
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
view: str = Query("identified", pattern="^(identified|anonymous)$"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Paginated, filterable list of the org's people.
|
||||
|
||||
The default view is identified people (real email, phone, or a qualifying
|
||||
capture); anonymous browser sessions live behind view=anonymous — they are
|
||||
a lead-capture funnel signal, not directory content."""
|
||||
_require_people_access(current_user, db)
|
||||
items, total = PeopleRepository(db).list_people(
|
||||
current_user.organization_id, stage=stage, search=search, page=page,
|
||||
page_size=page_size, view=view,
|
||||
)
|
||||
return PeopleListResponse(
|
||||
items=[PersonListItem(**i) for i in items],
|
||||
total=total, page=page, page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=PeopleStats)
|
||||
async def people_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""KPI counters for the People page header."""
|
||||
_require_people_access(current_user, db)
|
||||
return PeopleStats(**PeopleRepository(db).get_stats(current_user.organization_id))
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=PersonDetail)
|
||||
async def get_person(
|
||||
customer_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Full profile: captured attributes, lifecycle timeline, conversations."""
|
||||
_require_people_access(current_user, db)
|
||||
detail = PeopleRepository(db).get_detail(current_user.organization_id, customer_id)
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="Person not found")
|
||||
return PersonDetail(**detail)
|
||||
|
||||
|
||||
@router.post("/{customer_id}/mark-customer", response_model=PersonDetail)
|
||||
async def mark_as_customer(
|
||||
customer_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Manually promote a person to the Customer stage (phase 1: no automated signal)."""
|
||||
_require_people_access(current_user, db, PEOPLE_WRITE_PERMISSIONS)
|
||||
repo = PeopleRepository(db)
|
||||
customer = repo.get_customer(current_user.organization_id, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Person not found")
|
||||
# An anonymous browser session cannot BE a customer — there is no one to
|
||||
# recognize. Identify them first (the drawer's edit adds a phone/name).
|
||||
if not repo.is_identified(customer, repo._has_qualified_capture(customer.id)):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="Add an email or phone first — this person is anonymous")
|
||||
repo.mark_customer(current_user.organization_id, customer_id)
|
||||
detail = repo.get_detail(current_user.organization_id, customer_id)
|
||||
return PersonDetail(**detail)
|
||||
|
||||
|
||||
@router.patch("/{customer_id}", response_model=PersonDetail)
|
||||
async def update_person(
|
||||
customer_id: UUID,
|
||||
request: PersonUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Explicit human edit of a person's name/phone — the identification tool.
|
||||
|
||||
This is the one path allowed to CORRECT a phone (the automatic capture
|
||||
paths are set-if-absent): a mistyped outbound number must be fixable."""
|
||||
_require_people_access(current_user, db, PEOPLE_WRITE_PERMISSIONS)
|
||||
repo = PeopleRepository(db)
|
||||
customer, error = repo.update_person(
|
||||
current_user.organization_id, customer_id,
|
||||
full_name=request.full_name, phone=request.phone,
|
||||
)
|
||||
if customer is None:
|
||||
raise HTTPException(status_code=404, detail="Person not found")
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
detail = repo.get_detail(current_user.organization_id, customer.id)
|
||||
return PersonDetail(**detail)
|
||||
|
||||
|
||||
def _person_crm_status(db: Session, organization_id, customer_id) -> PersonCrmStatus:
|
||||
connected = [
|
||||
c.provider for c in CrmConnectionRepository(db).list_by_org(organization_id)
|
||||
if c.status == CrmConnectionStatus.ACTIVE.value
|
||||
]
|
||||
synced = CrmCustomerSyncRepository(db).list_for_customer(customer_id)
|
||||
return PersonCrmStatus(
|
||||
connected_providers=connected,
|
||||
synced=[CrmCustomerSyncOut.model_validate(s) for s in synced],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{customer_id}/crm", response_model=PersonCrmStatus)
|
||||
async def get_person_crm(
|
||||
customer_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""CRM state for the drawer: which CRMs the org has connected + where this
|
||||
person is already synced."""
|
||||
_require_people_access(current_user, db)
|
||||
repo = PeopleRepository(db)
|
||||
customer = repo.get_customer(current_user.organization_id, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Person not found")
|
||||
return _person_crm_status(db, current_user.organization_id, customer_id)
|
||||
|
||||
|
||||
@router.post("/{customer_id}/crm-sync", response_model=PersonCrmStatus)
|
||||
async def sync_person_crm(
|
||||
customer_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Manual "Sync now": push this person to every connected CRM, synchronously."""
|
||||
_require_people_access(current_user, db, PEOPLE_WRITE_PERMISSIONS)
|
||||
repo = PeopleRepository(db)
|
||||
customer = repo.get_customer(current_user.organization_id, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Person not found")
|
||||
# Debounce: a double-click shouldn't fire a second fan-out of CRM writes
|
||||
# while the first is still in flight. Best-effort (Redis only).
|
||||
redis_client = get_redis()
|
||||
if redis_client is not None and not redis_client.set(
|
||||
f"crm_manual_sync:{customer_id}", "1", nx=True, ex=MANUAL_SYNC_DEBOUNCE_SECONDS):
|
||||
return _person_crm_status(db, current_user.organization_id, customer_id)
|
||||
try:
|
||||
await sync_customer_to_crm(db, customer)
|
||||
except CrmManualSyncError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return _person_crm_status(db, current_user.organization_id, customer_id)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import (
|
||||
check_permissions,
|
||||
require_any_permission,
|
||||
require_grantable,
|
||||
require_permissions,
|
||||
)
|
||||
from app.repositories.role import RoleRepository
|
||||
from app.models.permission import Permission
|
||||
from app.core.params import DbIntId
|
||||
from app.models.schemas.role import (
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
RoleResponse,
|
||||
PermissionResponse
|
||||
)
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Reading the role catalogue is part of two surfaces: the Roles editor
|
||||
# (manage_roles) and the invite form, which needs the role dropdown
|
||||
# (manage_users). Gating reads on manage_roles alone breaks user invite.
|
||||
ROLE_READ_PERMISSIONS = ("manage_roles", "manage_users")
|
||||
|
||||
|
||||
@router.post("", response_model=RoleResponse)
|
||||
async def create_role(
|
||||
role_data: RoleCreate,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new role"""
|
||||
role_repo = RoleRepository(db)
|
||||
|
||||
# Check if default role already exists for org
|
||||
if role_data.is_default:
|
||||
existing_default = role_repo.get_default_role(current_user.organization_id)
|
||||
if existing_default:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Organization already has a default role"
|
||||
)
|
||||
|
||||
# Validate permissions exist
|
||||
if role_data.permissions:
|
||||
permission_ids = [p.id for p in role_data.permissions]
|
||||
existing_permissions = db.query(Permission)\
|
||||
.filter(Permission.id.in_(permission_ids))\
|
||||
.all()
|
||||
if len(existing_permissions) != len(permission_ids):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid permission"
|
||||
)
|
||||
# A new role is a grant too — otherwise the rule is one "create role,
|
||||
# then assign myself to it" away from being bypassed.
|
||||
require_grantable(current_user, {p.name for p in existing_permissions})
|
||||
|
||||
try:
|
||||
role = role_repo.create_role(
|
||||
name=role_data.name,
|
||||
description=role_data.description,
|
||||
organization_id=current_user.organization_id,
|
||||
is_default=role_data.is_default,
|
||||
permission_ids=[permission.id for permission in role_data.permissions]
|
||||
)
|
||||
return role
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Role with this name already exists"
|
||||
)
|
||||
|
||||
@router.get("", response_model=List[RoleResponse])
|
||||
async def list_roles(
|
||||
current_user: User = Depends(require_any_permission(*ROLE_READ_PERMISSIONS)),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List all roles in the organization"""
|
||||
role_repo = RoleRepository(db)
|
||||
return role_repo.get_roles_by_organization(current_user.organization_id)
|
||||
|
||||
@router.get("/{role_id}", response_model=RoleResponse)
|
||||
async def get_role(
|
||||
role_id: DbIntId,
|
||||
current_user: User = Depends(require_any_permission(*ROLE_READ_PERMISSIONS)),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get role details including permissions"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
return role
|
||||
|
||||
@router.put("/{role_id}", response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: DbIntId,
|
||||
role_data: RoleUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update role details"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
# Prevent editing default role
|
||||
if role.is_default:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot modify default role"
|
||||
)
|
||||
|
||||
# Check default role constraints
|
||||
if role_data.is_default:
|
||||
existing_default = role_repo.get_default_role(current_user.organization_id)
|
||||
if existing_default and existing_default.id != role_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Organization already has a default role"
|
||||
)
|
||||
|
||||
# Validate permissions if provided
|
||||
if role_data.permissions:
|
||||
permission_ids = [p.id for p in role_data.permissions]
|
||||
existing_permissions = db.query(Permission)\
|
||||
.filter(Permission.id.in_(permission_ids))\
|
||||
.all()
|
||||
if len(existing_permissions) != len(permission_ids):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid permission"
|
||||
)
|
||||
# Only the diff: re-saving a role that already holds a permission the
|
||||
# caller lacks is legitimate; adding one is the escalation.
|
||||
already_held = {p.name for p in role.permissions}
|
||||
require_grantable(
|
||||
current_user,
|
||||
{p.name for p in existing_permissions} - already_held,
|
||||
)
|
||||
|
||||
try:
|
||||
updated_role = role_repo.update_role(
|
||||
role_id,
|
||||
**role_data.model_dump(exclude_unset=True) # Changed from dict() to model_dump()
|
||||
)
|
||||
return updated_role
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Role with this name already exists"
|
||||
)
|
||||
|
||||
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_role(
|
||||
role_id: DbIntId,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a role"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
if role.is_default:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete default role"
|
||||
)
|
||||
|
||||
if role_repo.is_role_in_use(role_id):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete role that is assigned to users"
|
||||
)
|
||||
|
||||
role_repo.delete_role(role_id)
|
||||
|
||||
@router.post("/{role_id}/permissions/{permission}")
|
||||
async def add_role_permission(
|
||||
role_id: DbIntId,
|
||||
permission: str,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a permission to a role"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
# Resolve the name before the grant check so an unknown permission still
|
||||
# reads as 404 rather than "you may not grant that".
|
||||
if not db.query(Permission).filter(Permission.name == permission).first():
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
require_grantable(current_user, [permission])
|
||||
|
||||
success = role_repo.add_permission(role_id, permission)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
return {"message": "Permission added to role"}
|
||||
|
||||
@router.delete("/{role_id}/permissions/{permission}")
|
||||
async def remove_role_permission(
|
||||
role_id: DbIntId,
|
||||
permission: str,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove a permission from a role"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
success = role_repo.remove_permission(role_id, permission)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
return {"message": "Permission removed from role"}
|
||||
|
||||
@router.get("/permissions/all", response_model=List[PermissionResponse])
|
||||
async def list_permissions(
|
||||
current_user: User = Depends(require_any_permission(*ROLE_READ_PERMISSIONS)),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List all available permissions"""
|
||||
return db.query(Permission).all()
|
||||
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.widget import Widget
|
||||
from app.models.customer import Customer
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
verify_conversation_token,
|
||||
get_existing_valid_token_jti,
|
||||
revoke_token as security_revoke_token,
|
||||
)
|
||||
from app.services import conversation_token
|
||||
from app.repositories.widget_app import WidgetAppRepository
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from pydantic import BaseModel, field_validator
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# custom_data is embedded in both the JWT and the customer's stored meta_data, so cap
|
||||
# its size to keep tokens small and prevent unbounded growth of the DB column.
|
||||
CUSTOM_DATA_MAX_KEYS = 20
|
||||
CUSTOM_DATA_MAX_BYTES = 4096
|
||||
|
||||
# ============================================================================
|
||||
# MODELS
|
||||
# ============================================================================
|
||||
|
||||
class GenerateTokenRequest(BaseModel):
|
||||
"""Request to generate widget token - requires API key in Authorization header"""
|
||||
widget_id: str
|
||||
customer_email: Optional[str] = None
|
||||
customer_name: Optional[str] = None
|
||||
custom_data: Optional[Dict[str, Any]] = None
|
||||
ttl_seconds: Optional[int] = 3600 # Default 1 hour
|
||||
|
||||
@field_validator('custom_data')
|
||||
@classmethod
|
||||
def validate_custom_data_size(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
if len(value) > CUSTOM_DATA_MAX_KEYS:
|
||||
raise ValueError(f"custom_data supports at most {CUSTOM_DATA_MAX_KEYS} keys")
|
||||
size = len(json.dumps(value))
|
||||
if size > CUSTOM_DATA_MAX_BYTES:
|
||||
raise ValueError(f"custom_data must be at most {CUSTOM_DATA_MAX_BYTES} bytes when serialized")
|
||||
return value
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Request to rotate a still-valid conversation token"""
|
||||
widget_id: str
|
||||
|
||||
class RevokeTokenRequest(BaseModel):
|
||||
"""Request to revoke a token"""
|
||||
token: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
class RevokeTokenResponse(BaseModel):
|
||||
"""Response for token revocation"""
|
||||
success: bool
|
||||
message: str
|
||||
revoked_at: Optional[str] = None
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Token response data"""
|
||||
token: str
|
||||
widget_id: str
|
||||
expires_in: int
|
||||
expires_at: str
|
||||
created_at: str
|
||||
|
||||
class GenerateTokenResponse(BaseModel):
|
||||
"""Response with generated token"""
|
||||
success: bool
|
||||
data: TokenData
|
||||
message: Optional[str] = None
|
||||
|
||||
# ============================================================================
|
||||
# DEPENDENCIES
|
||||
# ============================================================================
|
||||
|
||||
def validate_api_key(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Validate API key from Authorization header (Bearer token) against the
|
||||
widget_apps table. A plain def, so FastAPI runs it in a worker thread:
|
||||
a key issued before the lookup column is bcrypt-checked, which must not
|
||||
hold up the event loop.
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing or invalid Authorization header. Use 'Bearer YOUR_API_KEY'",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
api_key = auth_header[len("Bearer "):]
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="API key cannot be empty",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
repo = WidgetAppRepository(db)
|
||||
widget_app = repo.validate_api_key(api_key)
|
||||
|
||||
if not widget_app:
|
||||
logger.warning(f"Invalid API key attempt")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Store widget_app in request state for downstream use
|
||||
request.state.widget_app = widget_app
|
||||
|
||||
return api_key
|
||||
|
||||
# ============================================================================
|
||||
# ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/generate-token", response_model=GenerateTokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def generate_widget_token(
|
||||
body: GenerateTokenRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
api_key: str = Depends(validate_api_key)
|
||||
):
|
||||
"""
|
||||
Generate a JWT token for widget authentication.
|
||||
|
||||
Only calls with valid API key (in Authorization header) will succeed.
|
||||
Frontend receives this token and must pass it to the widget.
|
||||
|
||||
The token is signed with CONVERSATION_SECRET_KEY and can be verified by the widget.
|
||||
|
||||
Args:
|
||||
body: GenerateTokenRequest with widget_id, customer_email, customer_name, ttl_seconds
|
||||
request: FastAPI request object (contains widget_app from API key validation)
|
||||
db: Database session
|
||||
api_key: Validated API key from Authorization header
|
||||
|
||||
Returns:
|
||||
GenerateTokenResponse with JWT token
|
||||
|
||||
Raises:
|
||||
HTTPException 401: Invalid or missing API key
|
||||
HTTPException 404: Widget not found
|
||||
HTTPException 400: Invalid widget_id
|
||||
|
||||
Example:
|
||||
POST /api/v1/generate-token
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
{
|
||||
"widget_id": "widget-123",
|
||||
"customer_email": "user@example.com",
|
||||
"customer_name": "John Doe",
|
||||
"ttl_seconds": 3600
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"widget_id": "widget-123",
|
||||
"expires_in": 3600,
|
||||
"expires_at": "2024-11-09T15:00:00",
|
||||
"created_at": "2024-11-09T14:00:00"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Get widget_app from request state (set by validate_api_key)
|
||||
widget_app = request.state.widget_app
|
||||
|
||||
# SECURITY: Validate widget exists AND belongs to same organization as the API key
|
||||
# This prevents cross-organization token generation
|
||||
widget = db.query(Widget).filter(
|
||||
Widget.id == body.widget_id,
|
||||
Widget.organization_id == widget_app.organization_id
|
||||
).first()
|
||||
|
||||
if not widget:
|
||||
logger.error(f"Widget not found or access denied: {body.widget_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Widget '{body.widget_id}' not found"
|
||||
)
|
||||
|
||||
# Create or update customer
|
||||
customer = None
|
||||
|
||||
if body.customer_email:
|
||||
# The embedding app supplied a known identity, so this is an
|
||||
# authenticated/integration customer (excluded from the People/leads
|
||||
# views), not an organic lead. Shared with the hosted support chat,
|
||||
# which identifies the signed-in dashboard user the same way.
|
||||
customer = CustomerRepository(db).identify(
|
||||
email=body.customer_email,
|
||||
organization_id=widget.organization_id,
|
||||
full_name=body.customer_name,
|
||||
meta_data=body.custom_data,
|
||||
)
|
||||
else:
|
||||
# No email provided - create anonymous customer
|
||||
# Generate a unique email for anonymous customers
|
||||
import time
|
||||
timestamp = int(time.time() * 1000)
|
||||
anonymous_email = f"anonymous-{timestamp}@{widget.organization_id}.local"
|
||||
|
||||
customer = Customer(
|
||||
email=anonymous_email,
|
||||
full_name=body.customer_name or "Anonymous",
|
||||
meta_data=body.custom_data,
|
||||
organization_id=widget.organization_id
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
logger.info(f"Created anonymous customer: {customer.id}")
|
||||
|
||||
# Validate TTL
|
||||
if not conversation_token.is_ttl_supported(body.ttl_seconds):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"ttl_seconds must be between {conversation_token.MIN_TTL_SECONDS} and "
|
||||
f"{conversation_token.MAX_TTL_SECONDS} (1 minute to 24 hours) for security "
|
||||
"and performance reasons"
|
||||
)
|
||||
)
|
||||
|
||||
# Check for existing valid token for this customer/widget combination
|
||||
# This prevents token multiplication when user refreshes page
|
||||
customer_email = body.customer_email or customer.email
|
||||
existing_jti = get_existing_valid_token_jti(customer_email, body.widget_id, str(customer.id))
|
||||
if existing_jti:
|
||||
# Only the JTI is kept in Redis, so the token itself is re-signed - reusing
|
||||
# the ID keeps this customer to one revocable session per widget.
|
||||
logger.info(f"Found existing valid token for customer {customer.id} in widget {body.widget_id}, reusing JTI")
|
||||
|
||||
token, expires_at, ttl = conversation_token.mint(
|
||||
{
|
||||
"sub": str(customer.id), # Sub is now always the customer_id
|
||||
"widget_id": body.widget_id,
|
||||
"customer_email": customer_email,
|
||||
"customer_name": body.customer_name or customer.full_name,
|
||||
"customer_id": str(customer.id), # Explicit customer_id claim
|
||||
"custom_data": body.custom_data,
|
||||
},
|
||||
ttl_seconds=body.ttl_seconds,
|
||||
jti=existing_jti,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if not existing_jti:
|
||||
logger.info(f"Generated new widget token for widget_id={body.widget_id}, customer_email={customer_email}, expires_in={ttl}s")
|
||||
else:
|
||||
logger.info(f"Reused JTI and refreshed Redis TTL for widget_id={body.widget_id}, customer_email={customer_email}, jti={existing_jti[:8]}...")
|
||||
|
||||
return GenerateTokenResponse(
|
||||
success=True,
|
||||
data=TokenData(
|
||||
token=token,
|
||||
widget_id=body.widget_id,
|
||||
expires_in=ttl,
|
||||
expires_at=expires_at.isoformat(),
|
||||
created_at=now.isoformat()
|
||||
),
|
||||
message=f"Token generated successfully. Expires in {ttl} seconds."
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error generating token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate token"
|
||||
)
|
||||
|
||||
@router.post("/refresh-token", response_model=GenerateTokenResponse)
|
||||
async def refresh_widget_token(
|
||||
body: RefreshTokenRequest,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Rotate a still-valid conversation token, keeping the visitor's identity.
|
||||
|
||||
The widget calls this before its token lapses, so a page left open longer than
|
||||
`ttl_seconds` doesn't silently drop an identified visitor back to anonymous.
|
||||
|
||||
No API key: the request is authenticated by the conversation token it carries,
|
||||
which the caller already holds, so rotating it grants nothing new. An expired or
|
||||
revoked token is refused - only POST /generate-token (API key) can assert an
|
||||
identity - and rotation stops once the chain reaches its absolute ceiling.
|
||||
|
||||
Args:
|
||||
body: RefreshTokenRequest with the widget_id the token was issued for
|
||||
authorization: `Bearer <conversation token>`
|
||||
|
||||
Returns:
|
||||
GenerateTokenResponse with the replacement token
|
||||
|
||||
Raises:
|
||||
HTTPException 401: missing, invalid, expired or revoked conversation token
|
||||
"""
|
||||
token = authorization[len("Bearer "):].strip() if authorization and authorization.startswith("Bearer ") else None
|
||||
state = conversation_token.inspect(token, body.widget_id)
|
||||
|
||||
if not state.is_live:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"code": (
|
||||
conversation_token.IDENTITY_EXPIRED_CODE if state.identity_lost
|
||||
else conversation_token.INVALID_TOKEN_CODE
|
||||
),
|
||||
"message": "Conversation token is no longer valid. Issue a new one with /generate-token.",
|
||||
},
|
||||
)
|
||||
|
||||
rotated = conversation_token.rotate(state.payload)
|
||||
if not rotated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"code": conversation_token.IDENTITY_EXPIRED_CODE,
|
||||
"message": "Conversation token has reached its maximum lifetime. Issue a new one with /generate-token.",
|
||||
},
|
||||
)
|
||||
|
||||
new_token, expires_at, ttl = rotated
|
||||
logger.info(f"Rotated conversation token for widget_id={body.widget_id}, expires_in={ttl}s")
|
||||
|
||||
return GenerateTokenResponse(
|
||||
success=True,
|
||||
data=TokenData(
|
||||
token=new_token,
|
||||
widget_id=body.widget_id,
|
||||
expires_in=ttl,
|
||||
expires_at=expires_at.isoformat(),
|
||||
created_at=datetime.now(timezone.utc).isoformat()
|
||||
),
|
||||
message=f"Token refreshed successfully. Expires in {ttl} seconds."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify-token")
|
||||
async def verify_widget_token(
|
||||
token: str,
|
||||
widget_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
api_key: str = Depends(validate_api_key)
|
||||
):
|
||||
"""
|
||||
Verify a widget token (for backend validation).
|
||||
|
||||
Args:
|
||||
token: JWT token to verify
|
||||
widget_id: Widget ID to match against token
|
||||
db: Database session
|
||||
api_key: Validated API key from Authorization header
|
||||
|
||||
Returns:
|
||||
Token data if valid, error if invalid
|
||||
|
||||
Example:
|
||||
POST /api/v1/verify-token?token=eyJ...&widget_id=widget-123
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
"""
|
||||
try:
|
||||
# Verify JWT signature AND check Redis revocation status
|
||||
# This ensures revoked tokens are rejected
|
||||
payload = verify_conversation_token(token)
|
||||
|
||||
if not payload:
|
||||
logger.warning(f"Token invalid or revoked for widget_id={widget_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token is invalid or has been revoked"
|
||||
)
|
||||
|
||||
# Verify widget_id matches
|
||||
if payload.get("widget_id") != widget_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token widget_id does not match provided widget_id"
|
||||
)
|
||||
|
||||
logger.info(f"Token verified successfully for widget_id={widget_id}")
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"token_data": payload
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error verifying token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to verify token"
|
||||
)
|
||||
|
||||
@router.post("/revoke-token", response_model=RevokeTokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def revoke_widget_token(
|
||||
body: RevokeTokenRequest,
|
||||
db: Session = Depends(get_db),
|
||||
api_key: str = Depends(validate_api_key)
|
||||
):
|
||||
"""
|
||||
Revoke a widget authentication token immediately.
|
||||
|
||||
Once revoked, a token becomes invalid even if it hasn't expired naturally.
|
||||
|
||||
Use cases:
|
||||
- User logout: Revoke tokens when user logs out
|
||||
- Permission changes: Invalidate tokens when user role/permissions change
|
||||
- Security incident: Revoke compromised tokens
|
||||
- Manual revocation: Admin/portal revokes a specific token
|
||||
|
||||
Args:
|
||||
body: RevokeTokenRequest with token and optional reason
|
||||
db: Database session
|
||||
api_key: Validated API key from Authorization header
|
||||
|
||||
Returns:
|
||||
RevokeTokenResponse indicating success/failure
|
||||
|
||||
Example:
|
||||
POST /api/v1/revoke-token
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"reason": "User logged out"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Token revoked successfully",
|
||||
"revoked_at": "2024-11-09T14:05:00"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
token = body.token
|
||||
reason = body.reason or "Manual revocation via API"
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token cannot be empty"
|
||||
)
|
||||
|
||||
# Expired tokens stay revocable: only the signature has to hold up here.
|
||||
payload = conversation_token.claims_ignoring_expiry(token)
|
||||
if payload is None:
|
||||
logger.warning("Invalid token format for revocation")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid token format"
|
||||
)
|
||||
|
||||
# Extract token information
|
||||
jti = payload.get("jti")
|
||||
|
||||
if not jti:
|
||||
logger.warning("Token does not have JTI claim, cannot revoke")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token does not support revocation (missing JTI claim)"
|
||||
)
|
||||
|
||||
# Revoke the token
|
||||
security_revoke_token(jti)
|
||||
|
||||
revoked_at = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
f"Token revoked: jti={jti[:8]}... reason={reason}"
|
||||
)
|
||||
|
||||
return RevokeTokenResponse(
|
||||
success=True,
|
||||
message=f"Token revoked successfully. Reason: {reason}",
|
||||
revoked_at=revoked_at.isoformat()
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error revoking token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to revoke token"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import require_permissions
|
||||
from app.repositories.user_group import UserGroupRepository
|
||||
from app.models.schemas.user_group import (
|
||||
UserGroupCreate,
|
||||
UserGroupUpdate,
|
||||
UserGroupResponse,
|
||||
UserGroupWithUsers
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("", response_model=List[UserGroupWithUsers])
|
||||
async def list_groups(
|
||||
current_user: User = Depends(require_permissions("manage_users")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List all groups in the organization"""
|
||||
group_repo = UserGroupRepository(db)
|
||||
return group_repo.get_groups_by_organization(current_user.organization_id)
|
||||
|
||||
@router.post("", response_model=UserGroupResponse)
|
||||
async def create_group(
|
||||
group_data: UserGroupCreate,
|
||||
current_user: User = Depends(require_permissions("manage_users")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new group"""
|
||||
group_repo = UserGroupRepository(db)
|
||||
return group_repo.create_group(
|
||||
name=group_data.name,
|
||||
description=group_data.description,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
@router.get("/{group_id}", response_model=UserGroupWithUsers)
|
||||
async def get_group(
|
||||
group_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_users")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get group details including members"""
|
||||
group_repo = UserGroupRepository(db)
|
||||
group = group_repo.get_group(group_id)
|
||||
|
||||
if not group or group.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
return group
|
||||
|
||||
@router.put("/{group_id}", response_model=UserGroupResponse)
|
||||
async def update_group(
|
||||
group_id: UUID,
|
||||
group_data: UserGroupUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_users")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update group details"""
|
||||
group_repo = UserGroupRepository(db)
|
||||
group = group_repo.get_group(group_id)
|
||||
|
||||
if not group or group.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
return group_repo.update_group(group_id, **group_data.dict(exclude_unset=True))
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_group(
|
||||
group_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_users")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a group"""
|
||||
group_repo = UserGroupRepository(db)
|
||||
group = group_repo.get_group(group_id)
|
||||
|
||||
if not group or group.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
group_repo.delete_group(group_id)
|
||||
|
||||
@router.post("/{group_id}/users/{user_id}")
|
||||
async def add_user_to_group(
|
||||
group_id: UUID,
|
||||
user_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_users")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a user to a group"""
|
||||
group_repo = UserGroupRepository(db)
|
||||
group = group_repo.get_group(group_id)
|
||||
|
||||
if not group or group.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
success = group_repo.add_user(group_id, user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to add user to group")
|
||||
return {"message": "User added to group"}
|
||||
|
||||
@router.delete("/{group_id}/users/{user_id}")
|
||||
async def remove_user_from_group(
|
||||
group_id: UUID,
|
||||
user_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_users")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove a user from a group"""
|
||||
group_repo = UserGroupRepository(db)
|
||||
group = group_repo.get_group(group_id)
|
||||
|
||||
if not group or group.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
success = group_repo.remove_user(group_id, user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to remove user from group")
|
||||
return {"message": "User removed from group"}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Server-side sessions behind the dashboard JWTs.
|
||||
|
||||
The tokens are stateless, so on their own signing out only deleted cookies: a
|
||||
copied access token kept working for its 30 minutes and a refresh token kept
|
||||
minting new ones for 7 days, and a password change ended nothing. Every token
|
||||
now names an auth_sessions row in its `sid` claim, and a token is only live
|
||||
while that row is.
|
||||
|
||||
Tokens without a `sid` (issued before this existed, or by the enterprise signup
|
||||
flow) are accepted until they expire, so deploying this signs nobody out.
|
||||
|
||||
None of these functions commit: the session row belongs in the caller's
|
||||
transaction, alongside whatever it is doing (a login, an org setup, a password
|
||||
change).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_token
|
||||
from app.models.auth_session import AuthSession
|
||||
|
||||
ACCESS = "access"
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
def issue_tokens(db: Session, user_id, org_id, sid=None) -> Tuple[str, str]:
|
||||
"""Return (access_token, refresh_token) for a session.
|
||||
|
||||
Without `sid` a new session is opened (a login). With the `sid` of a live
|
||||
session the tokens are re-minted for it (a refresh), so revoking it still
|
||||
ends everything that came from the same login.
|
||||
"""
|
||||
if sid is None:
|
||||
session = AuthSession(user_id=_as_uuid(user_id))
|
||||
db.add(session)
|
||||
db.flush()
|
||||
sid = session.id
|
||||
|
||||
token_data = {"sub": str(user_id), "org": str(org_id), "sid": str(sid)}
|
||||
return create_access_token(token_data), create_refresh_token(token_data)
|
||||
|
||||
|
||||
def live_payload(db: Session, token: str, expected_type: str) -> Optional[dict]:
|
||||
"""The token's payload if it is valid, of `expected_type` and not revoked.
|
||||
|
||||
The type check keeps a 7-day refresh token from being used as an access
|
||||
token. A token without a `sid` predates sessions and is let through.
|
||||
"""
|
||||
payload = verify_token(token)
|
||||
if not payload or payload.get("type") != expected_type:
|
||||
return None
|
||||
|
||||
sid = payload.get("sid")
|
||||
if sid is None:
|
||||
return payload
|
||||
|
||||
session_id = _as_uuid(sid)
|
||||
if session_id is None:
|
||||
return None
|
||||
session = db.query(AuthSession).filter(AuthSession.id == session_id).first()
|
||||
if (
|
||||
session is None
|
||||
or session.revoked_at is not None
|
||||
or str(session.user_id) != str(payload.get("sub"))
|
||||
):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def session_id_of(token: Optional[str]) -> Optional[str]:
|
||||
"""The `sid` a correctly signed token carries, or None."""
|
||||
payload = verify_token(token) if token else None
|
||||
return payload.get("sid") if payload else None
|
||||
|
||||
|
||||
def revoke_session(db: Session, sid) -> None:
|
||||
"""End one session; both of its tokens stop working."""
|
||||
session_id = _as_uuid(sid)
|
||||
if session_id is None:
|
||||
return
|
||||
db.query(AuthSession).filter(
|
||||
AuthSession.id == session_id,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
).update({"revoked_at": datetime.now(timezone.utc)}, synchronize_session=False)
|
||||
|
||||
|
||||
def revoke_user_sessions(db: Session, user_id, keep_sid=None) -> None:
|
||||
"""End every session the user has, except `keep_sid` when given."""
|
||||
query = db.query(AuthSession).filter(
|
||||
AuthSession.user_id == _as_uuid(user_id),
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
keep = _as_uuid(keep_sid) if keep_sid is not None else None
|
||||
if keep is not None:
|
||||
query = query.filter(AuthSession.id != keep)
|
||||
query.update({"revoked_at": datetime.now(timezone.utc)}, synchronize_session=False)
|
||||
|
||||
|
||||
def _as_uuid(value) -> Optional[UUID]:
|
||||
if isinstance(value, UUID):
|
||||
return value
|
||||
try:
|
||||
return UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
"""Turn the chat-scope toggles on the user form into a role.
|
||||
|
||||
Permissions hang off roles, not off people, so "let this agent read every
|
||||
conversation while their colleague is on leave" has to resolve to *some* role.
|
||||
Rather than inventing per-user grants — which would mean every permission check
|
||||
in the codebase learning about a second source of truth — the two toggles
|
||||
describe the chat scope a person should have, and this module finds the role in
|
||||
their organization that matches. A new role is created only when no existing
|
||||
one does, so the common case adds nothing to the Roles screen.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Set
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.auth import require_grantable
|
||||
from app.core.logger import get_logger
|
||||
from app.models.permission import Permission
|
||||
from app.models.role import Role
|
||||
from app.models.user import User
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The unclaimed queue: sessions the AI is still handling that nobody has taken.
|
||||
AI_QUEUE_PERMISSION = "view_unassigned_chats"
|
||||
# Every conversation in the organization, including other agents' assignments.
|
||||
ALL_CHATS_PERMISSION = "view_all_chats"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatScope:
|
||||
"""What the two toggles on the user form are asking for."""
|
||||
|
||||
see_all_ai_chats: bool
|
||||
see_all_org_chats: bool
|
||||
|
||||
@classmethod
|
||||
def of(cls, role: Role) -> "ChatScope":
|
||||
"""The scope a role already grants — what the form shows on open."""
|
||||
names = {p.name for p in role.permissions}
|
||||
return cls(
|
||||
see_all_ai_chats=AI_QUEUE_PERMISSION in names,
|
||||
see_all_org_chats=ALL_CHATS_PERMISSION in names,
|
||||
)
|
||||
|
||||
def label(self) -> str:
|
||||
if self.see_all_org_chats and self.see_all_ai_chats:
|
||||
return "all chats"
|
||||
if self.see_all_org_chats:
|
||||
return "all chats, no AI queue"
|
||||
if self.see_all_ai_chats:
|
||||
return "assigned + AI queue"
|
||||
return "assigned only"
|
||||
|
||||
|
||||
def resolve_role(
|
||||
db: Session,
|
||||
current_user: User,
|
||||
base_role: Role,
|
||||
see_all_ai_chats: Optional[bool],
|
||||
see_all_org_chats: Optional[bool],
|
||||
) -> Role:
|
||||
"""The role that grants `base_role` plus/minus the requested chat scope.
|
||||
|
||||
Either toggle may be None, meaning "whatever the role already does" — an
|
||||
API client that has never heard of these fields gets exactly the old
|
||||
behaviour.
|
||||
"""
|
||||
current = ChatScope.of(base_role)
|
||||
wanted = ChatScope(
|
||||
see_all_ai_chats=(
|
||||
current.see_all_ai_chats if see_all_ai_chats is None else see_all_ai_chats
|
||||
),
|
||||
see_all_org_chats=(
|
||||
current.see_all_org_chats if see_all_org_chats is None else see_all_org_chats
|
||||
),
|
||||
)
|
||||
if wanted == current:
|
||||
return base_role
|
||||
|
||||
held = {p.name for p in base_role.permissions}
|
||||
# super_admin already passes every check, so widening it would create a
|
||||
# near-duplicate role that grants nothing new.
|
||||
if "super_admin" in held:
|
||||
return base_role
|
||||
|
||||
desired = set(held)
|
||||
for granted, permission in (
|
||||
(wanted.see_all_ai_chats, AI_QUEUE_PERMISSION),
|
||||
(wanted.see_all_org_chats, ALL_CHATS_PERMISSION),
|
||||
):
|
||||
if granted:
|
||||
desired.add(permission)
|
||||
else:
|
||||
desired.discard(permission)
|
||||
|
||||
# Same rule as the Roles editor: an admin cannot hand out what they do not
|
||||
# hold themselves. Only the added permissions are checked, not the base
|
||||
# role's own — someone with manage_users can already assign any role in the
|
||||
# org as it stands, so carrying its permissions across grants nothing new.
|
||||
require_grantable(current_user, desired - held)
|
||||
|
||||
existing = _role_granting(db, base_role.organization_id, desired)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return _create_scoped_role(db, base_role, desired, wanted)
|
||||
|
||||
|
||||
#: Every label label() can produce, so a name can be stripped back down again.
|
||||
SCOPE_LABELS = tuple(
|
||||
ChatScope(ai, org).label() for ai in (True, False) for org in (True, False)
|
||||
)
|
||||
_SCOPE_SUFFIX = re.compile(
|
||||
r" \((?:%s)\)(?: \d+)?$" % "|".join(re.escape(label) for label in SCOPE_LABELS)
|
||||
)
|
||||
|
||||
|
||||
def _unscoped(name: str) -> str:
|
||||
"""`Agent (all chats)` -> `Agent`.
|
||||
|
||||
Re-scoping someone who is already on a derived role would otherwise stack
|
||||
suffixes: "Agent (all chats) (assigned only)".
|
||||
"""
|
||||
return _SCOPE_SUFFIX.sub("", name)
|
||||
|
||||
|
||||
def _role_granting(db: Session, organization_id: UUID, desired: Set[str]) -> Optional[Role]:
|
||||
"""An existing role in the org whose permissions are exactly `desired`.
|
||||
|
||||
Exact, not superset: a role that also grants manage_users would hand the
|
||||
person far more than the toggle asked for.
|
||||
"""
|
||||
roles = (
|
||||
db.query(Role)
|
||||
.options(joinedload(Role.permissions))
|
||||
.filter(Role.organization_id == organization_id)
|
||||
.all()
|
||||
)
|
||||
for role in roles:
|
||||
if {p.name for p in role.permissions} == desired:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def _create_scoped_role(
|
||||
db: Session, base_role: Role, desired: Set[str], scope: ChatScope
|
||||
) -> Role:
|
||||
role = Role(
|
||||
name=_available_name(db, base_role, scope),
|
||||
description=f"{_unscoped(base_role.name)}, scoped to {scope.label()}",
|
||||
organization_id=base_role.organization_id,
|
||||
# Never the org default. get_default_role() picks the default for
|
||||
# invited users, and a scope chosen for one person is not that.
|
||||
is_default=False,
|
||||
)
|
||||
role.permissions = (
|
||||
db.query(Permission).filter(Permission.name.in_(desired)).all()
|
||||
)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
logger.info(
|
||||
"Created chat-scope role %r (%s) for organization %s",
|
||||
role.name, scope.label(), base_role.organization_id,
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
def _available_name(db: Session, base_role: Role, scope: ChatScope) -> str:
|
||||
"""`Agent (all chats)`, or the first free numbered variant of it.
|
||||
|
||||
Role names are not unique in the schema, but two roles sharing a name in
|
||||
one org is confusing in every screen that lists them.
|
||||
"""
|
||||
taken = {
|
||||
name for (name,) in db.query(Role.name).filter(
|
||||
Role.organization_id == base_role.organization_id
|
||||
)
|
||||
}
|
||||
preferred = f"{_unscoped(base_role.name)} ({scope.label()})"
|
||||
if preferred not in taken:
|
||||
return preferred
|
||||
suffix = 2
|
||||
while f"{preferred} {suffix}" in taken:
|
||||
suffix += 1
|
||||
return f"{preferred} {suffix}"
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.repositories.fcm_token import FCMTokenRepository
|
||||
from app.core.logger import get_logger
|
||||
from firebase_admin import messaging
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Errors that identify the *token* as dead rather than the send as failed: the
|
||||
# browser revoked the subscription or cleared site data (Unregistered), or the
|
||||
# token belongs to a different Firebase project (SenderIdMismatch). Retrying
|
||||
# these never succeeds, so the row is pruned and the browser re-registers on
|
||||
# its next load.
|
||||
#
|
||||
# InvalidArgumentError is deliberately NOT here. FCM also returns it for a bad
|
||||
# *message* — an oversized data payload, say — which fails identically for
|
||||
# every token in the batch and would prune every device the user owns.
|
||||
DEAD_TOKEN_ERRORS = (
|
||||
messaging.UnregisteredError,
|
||||
messaging.SenderIdMismatchError,
|
||||
)
|
||||
|
||||
# send_each rejects a longer list outright. Chunking keeps an account that has
|
||||
# somehow accumulated more devices than this from raising before a single
|
||||
# message is sent — which would also stop the dead-token pruning below from
|
||||
# ever running, leaving that user permanently without push.
|
||||
FCM_SEND_BATCH_SIZE = 500
|
||||
|
||||
|
||||
async def send_fcm_notification(user_id: str, notification: Notification, db):
|
||||
"""Push a notification to every browser the user has signed in on."""
|
||||
try:
|
||||
token_repo = FCMTokenRepository(db)
|
||||
tokens = token_repo.get_tokens(user_id)
|
||||
if not tokens:
|
||||
# Logged, not silent: an account with notifications enabled but no
|
||||
# registered device looks identical to a broken push pipeline from
|
||||
# the outside, and used to leave no trace at all.
|
||||
logger.info(
|
||||
f"No FCM tokens registered for user {user_id}; "
|
||||
f"skipping push for notification {notification.id}"
|
||||
)
|
||||
return
|
||||
|
||||
metadata = notification.notification_metadata or {}
|
||||
|
||||
# Data-only FCM message (no messaging.Notification): with a notification
|
||||
# payload the Firebase web SDK auto-displays its own copy in the service
|
||||
# worker AND swallows clicks on it, so the app's tagged, deep-linking
|
||||
# notification would appear as a duplicate. The service worker renders
|
||||
# the notification itself from this data. session_id is flattened out of
|
||||
# the metadata so a click can deep-link to /conversations?session=<id>;
|
||||
# metadata is JSON (str() produced a Python repr the frontend could
|
||||
# never parse).
|
||||
data = {
|
||||
'title': notification.title or '',
|
||||
'body': notification.message or '',
|
||||
'type': notification.type,
|
||||
'notification_id': str(notification.id),
|
||||
'session_id': str(metadata.get('session_id') or ''),
|
||||
'metadata': json.dumps(metadata, default=str)
|
||||
}
|
||||
|
||||
messages = [
|
||||
messaging.Message(
|
||||
data=data,
|
||||
token=token,
|
||||
# These are web-push tokens, so the priority knob is the WebPush
|
||||
# `Urgency` header — NOT AndroidConfig/APNSConfig, which only
|
||||
# apply to native FCM SDK tokens and are ignored for web push.
|
||||
# High urgency asks the push service to deliver promptly even
|
||||
# while the device is idle / in battery-saver instead of
|
||||
# batching it; TTL keeps it deliverable for a day if the device
|
||||
# is offline.
|
||||
webpush=messaging.WebpushConfig(
|
||||
headers={'Urgency': 'high', 'TTL': '86400'},
|
||||
),
|
||||
)
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# send_each reports per-token outcomes, so one dead device does not
|
||||
# stop the others from being delivered to.
|
||||
dead_tokens = []
|
||||
delivered = 0
|
||||
for start in range(0, len(messages), FCM_SEND_BATCH_SIZE):
|
||||
chunk = messages[start:start + FCM_SEND_BATCH_SIZE]
|
||||
batch = messaging.send_each(chunk)
|
||||
delivered += batch.success_count
|
||||
|
||||
for token, result in zip(tokens[start:start + FCM_SEND_BATCH_SIZE],
|
||||
batch.responses):
|
||||
if result.success:
|
||||
continue
|
||||
if isinstance(result.exception, DEAD_TOKEN_ERRORS):
|
||||
dead_tokens.append(token)
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to send FCM notification {notification.id} to a device "
|
||||
f"of user {user_id}: {str(result.exception)}"
|
||||
)
|
||||
|
||||
if dead_tokens:
|
||||
removed = token_repo.remove_tokens(dead_tokens)
|
||||
logger.info(
|
||||
f"Pruned {removed} dead FCM token(s) for user {user_id}")
|
||||
|
||||
logger.info(
|
||||
f"Sent FCM notification {notification.id} to {delivered}/"
|
||||
f"{len(tokens)} device(s) of user {user_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send FCM notification: {str(e)}")
|
||||
@@ -0,0 +1,29 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const updateOrganization = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useAuth', () => ({
|
||||
useAuth: () => ({ user: ref({ organization_id: 'org-1' }) }),
|
||||
}))
|
||||
|
||||
vi.mock('@/services/organization', () => ({
|
||||
organizationService: {
|
||||
getOrganization: vi.fn(),
|
||||
getOrganizationStats: vi.fn(),
|
||||
updateOrganization: (...args: unknown[]) => updateOrganization(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useOrganizationSettings } = await import('@/composables/useOrganizationSettings')
|
||||
|
||||
describe('useOrganizationSettings – save errors', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the API detail when the domain is already taken', async () => {
|
||||
updateOrganization.mockRejectedValueOnce({
|
||||
message: 'Request failed with status code 409',
|
||||
response: { status: 409, data: { detail: 'That domain is already used by another organization.' } },
|
||||
})
|
||||
const settings = useOrganizationSettings()
|
||||
settings.formData.value.domain = 'localhost'
|
||||
|
||||
await settings.updateOrganization()
|
||||
|
||||
expect(updateOrganization).toHaveBeenCalledWith('org-1', expect.objectContaining({ domain: 'localhost' }))
|
||||
expect(settings.error.value).toBe('That domain is already used by another organization.')
|
||||
expect(settings.message.value).toBe('')
|
||||
expect(settings.hasChanges.value).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to a generic message when there is no API detail', async () => {
|
||||
updateOrganization.mockRejectedValueOnce({})
|
||||
const settings = useOrganizationSettings()
|
||||
settings.formData.value.name = 'Renamed'
|
||||
|
||||
await settings.updateOrganization()
|
||||
|
||||
expect(settings.error.value).toBe('更新组织信息失败')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { User } from '@/types/user'
|
||||
|
||||
const resetUserPassword = vi.fn()
|
||||
const toastSuccess = vi.fn()
|
||||
const toastError = vi.fn()
|
||||
|
||||
vi.mock('@/services/users', () => ({
|
||||
listUsers: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
resetUserPassword: (...args: unknown[]) => resetUserPassword(...args),
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => toastSuccess(...args),
|
||||
error: (...args: unknown[]) => toastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useUsers } = await import('@/composables/useUsers')
|
||||
|
||||
const agent = { id: 'agent-1', full_name: 'Agent One', email: 'agent@test.com' } as User
|
||||
|
||||
describe('useUsers – admin password reset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens the modal for the selected user', () => {
|
||||
const users = useUsers()
|
||||
users.handleResetPassword(agent)
|
||||
|
||||
expect(users.showResetPasswordModal.value).toBe(true)
|
||||
expect(users.selectedUser.value).toEqual(agent)
|
||||
})
|
||||
|
||||
it('resets the password and closes the modal', async () => {
|
||||
resetUserPassword.mockResolvedValueOnce(undefined)
|
||||
const users = useUsers()
|
||||
users.handleResetPassword(agent)
|
||||
|
||||
await users.confirmResetPassword('NewPassw0rd!')
|
||||
|
||||
expect(resetUserPassword).toHaveBeenCalledWith('agent-1', 'NewPassw0rd!')
|
||||
expect(users.showResetPasswordModal.value).toBe(false)
|
||||
expect(users.selectedUser.value).toBeNull()
|
||||
expect(toastSuccess).toHaveBeenCalled()
|
||||
expect(users.resettingPassword.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the modal open and surfaces the API message on failure', async () => {
|
||||
resetUserPassword.mockRejectedValueOnce({
|
||||
response: { data: { detail: [{ msg: 'Password must be at least 8 characters long' }] } },
|
||||
})
|
||||
const users = useUsers()
|
||||
users.handleResetPassword(agent)
|
||||
|
||||
await users.confirmResetPassword('short')
|
||||
|
||||
expect(users.showResetPasswordModal.value).toBe(true)
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
'出错了',
|
||||
expect.objectContaining({
|
||||
description: '重置密码失败:Password must be at least 8 characters long',
|
||||
}),
|
||||
)
|
||||
expect(users.resettingPassword.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, VueWrapper, flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { createRouter, createWebHistory, Router } from 'vue-router'
|
||||
import LoginView from '../../views/LoginView.vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
// Mock the services
|
||||
vi.mock('@/services/auth', () => ({
|
||||
authService: {
|
||||
login: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// The landing route is resolved from the real permission checks reading the
|
||||
// cached user, so this drives the user rather than mocking the checks away.
|
||||
const currentUser = vi.hoisted(() => ({ value: null as unknown }))
|
||||
|
||||
vi.mock('@/services/user', () => ({
|
||||
userService: {
|
||||
getCurrentUser: () => currentUser.value,
|
||||
setCurrentUser: vi.fn(),
|
||||
getUserId: () => 'user-1',
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock enterprise features
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: vi.fn(() => ({
|
||||
hasEnterpriseModule: false,
|
||||
loadModule: vi.fn().mockResolvedValue(null)
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock forgot password composable
|
||||
vi.mock('@/composables/useForgotPassword', () => ({
|
||||
useForgotPassword: vi.fn(() => ({
|
||||
isLoading: { value: false },
|
||||
error: { value: '' },
|
||||
success: { value: '' },
|
||||
currentStep: { value: 1 },
|
||||
email: { value: '' },
|
||||
otp: { value: '' },
|
||||
newPassword: { value: '' },
|
||||
confirmPassword: { value: '' },
|
||||
passwordValidation: { value: { score: 0, hasMinLength: false, hasUpperCase: false, hasLowerCase: false, hasNumber: false, hasSpecialChar: false } },
|
||||
requestPasswordReset: vi.fn(),
|
||||
verifyAndResetPassword: vi.fn(),
|
||||
resetForm: vi.fn(),
|
||||
goBackToEmailStep: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock Firebase services
|
||||
vi.mock('@/services/firebase', () => ({
|
||||
messaging: {},
|
||||
requestNotificationPermission: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNotifications', () => ({
|
||||
useNotifications: vi.fn(() => ({
|
||||
requestPermission: vi.fn(),
|
||||
hasPermission: { value: false }
|
||||
}))
|
||||
}))
|
||||
|
||||
// Import the mocked modules
|
||||
import { authService } from '@/services/auth'
|
||||
import { userWithPermissions } from '../fixtures/permissions'
|
||||
|
||||
describe('LoginView', () => {
|
||||
let wrapper: VueWrapper
|
||||
let router: Router
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a fresh router instance for each test
|
||||
router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{ path: '/ai-agents', name: 'ai-agents', component: { template: '<div>AI Agents</div>' } },
|
||||
{ path: '/conversations', name: 'conversations', component: { template: '<div>Conversations</div>' } },
|
||||
{ path: '/human-agents', name: 'human-agents', component: { template: '<div>Human Agents</div>' } },
|
||||
{ path: '/settings/organization', name: 'org-settings', component: { template: '<div>Organization Settings</div>' } },
|
||||
{ path: '/settings/ai-config', name: 'ai-config', component: { template: '<div>AI Config</div>' } },
|
||||
{ path: '/settings/user', name: 'user-settings', component: { template: '<div>User Settings</div>' } },
|
||||
{ path: '/403', name: 'forbidden', component: { template: '<div>403</div>' } }
|
||||
]
|
||||
})
|
||||
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset router to initial state and wait for it to be ready
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
// A user with no grants unless a test says otherwise
|
||||
currentUser.value = userWithPermissions([])
|
||||
|
||||
wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: {
|
||||
RouterView: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for component to be ready
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
it('renders login form properly', () => {
|
||||
expect(wrapper.find('.auth-page').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="email"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates email and password inputs', async () => {
|
||||
const emailInput = wrapper.find('input[type="email"]')
|
||||
const passwordInput = wrapper.find('input[type="password"]')
|
||||
|
||||
await emailInput.setValue('test@example.com')
|
||||
await passwordInput.setValue('password123')
|
||||
|
||||
expect((emailInput.element as HTMLInputElement).value).toBe('test@example.com')
|
||||
expect((passwordInput.element as HTMLInputElement).value).toBe('password123')
|
||||
})
|
||||
|
||||
it('shows loading state during login', async () => {
|
||||
const mockLogin = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
;(authService.login as any) = mockLogin
|
||||
|
||||
await wrapper.find('input[type="email"]').setValue('test@example.com')
|
||||
await wrapper.find('input[type="password"]').setValue('password123')
|
||||
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
await submitButton.trigger('submit')
|
||||
|
||||
expect(submitButton.text()).toBe('登录中…')
|
||||
expect(submitButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('handles successful login and redirects based on permissions', async () => {
|
||||
// Mock successful login
|
||||
;(authService.login as any).mockResolvedValue({ id: 1, email: 'test@example.com' })
|
||||
|
||||
// User can manage agents
|
||||
currentUser.value = userWithPermissions(['manage_agents'])
|
||||
|
||||
await wrapper.find('input[type="email"]').setValue('test@example.com')
|
||||
await wrapper.find('input[type="password"]').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
// Verify login was called
|
||||
expect(authService.login).toHaveBeenCalledWith('test@example.com', 'password123')
|
||||
|
||||
// Wait for all promises to resolve
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/ai-agents')
|
||||
})
|
||||
|
||||
it('shows the backend message authService.login rethrows', async () => {
|
||||
// What the real service throws: a plain Error carrying the API's detail.
|
||||
;(authService.login as any).mockRejectedValue(new Error('Incorrect email or password'))
|
||||
await wrapper.find('input[type="email"]').setValue('someone@example.com')
|
||||
await wrapper.find('input[type="password"]').setValue('wrong')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.auth-error').text()).toBe('Incorrect email or password')
|
||||
})
|
||||
|
||||
it('handles login error', async () => {
|
||||
const errorMessage = 'Invalid credentials'
|
||||
;(authService.login as any).mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
detail: errorMessage
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('input[type="email"]').setValue('test@example.com')
|
||||
await wrapper.find('input[type="password"]').setValue('wrong-password')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
// Wait for error to be displayed
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.auth-error').text()).toBe(errorMessage)
|
||||
})
|
||||
|
||||
it('redirects to correct route based on permissions', async () => {
|
||||
;(authService.login as any).mockResolvedValue({ id: 1, email: 'test@example.com' })
|
||||
|
||||
const testCases = [
|
||||
{ permission: 'manage_agents', route: '/ai-agents' },
|
||||
{ permission: 'view_assigned_chats', route: '/conversations' },
|
||||
{ permission: 'manage_users', route: '/human-agents' },
|
||||
{ permission: 'view_organization', route: '/settings/organization' },
|
||||
{ permission: 'view_ai_config', route: '/settings/ai-config' },
|
||||
]
|
||||
|
||||
for (const testCase of testCases) {
|
||||
currentUser.value = userWithPermissions([testCase.permission])
|
||||
|
||||
// Reset router and wait for it to be ready
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('input[type="email"]').setValue('test@example.com')
|
||||
await wrapper.find('input[type="password"]').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
// Wait for all promises to resolve
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe(testCase.route)
|
||||
}
|
||||
})
|
||||
|
||||
// Used to land on /403 — which was not a registered route, so the user
|
||||
// silently ended up on /ai-agents, a page they had just been refused.
|
||||
it('lands a permissionless user on their own settings, never on 403', async () => {
|
||||
;(authService.login as any).mockResolvedValue({ id: 1, email: 'test@example.com' })
|
||||
currentUser.value = userWithPermissions([])
|
||||
|
||||
await wrapper.find('input[type="email"]').setValue('test@example.com')
|
||||
await wrapper.find('input[type="password"]').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
// Wait for all promises to resolve
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/settings/user')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SetupView from '../../views/SetupView.vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
|
||||
// Mock timezone-select-js
|
||||
vi.mock('timezone-select-js', () => ({
|
||||
listTz: () => [
|
||||
{ value: 'America/New_York', label: 'New York' },
|
||||
{ value: 'Europe/London', label: 'London' },
|
||||
{ value: 'Asia/Tokyo', label: 'Tokyo' }
|
||||
],
|
||||
clientTz: () => 'America/New_York'
|
||||
}))
|
||||
|
||||
// Mock the organization service
|
||||
vi.mock('@/services/organization', () => ({
|
||||
createOrganization: vi.fn(),
|
||||
getSetupStatus: vi.fn().mockResolvedValue(false)
|
||||
}))
|
||||
|
||||
// Mock Firebase services
|
||||
vi.mock('@/services/firebase', () => ({
|
||||
messaging: {},
|
||||
requestNotificationPermission: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNotifications', () => ({
|
||||
useNotifications: vi.fn(() => ({
|
||||
requestPermission: vi.fn(),
|
||||
hasPermission: { value: false }
|
||||
}))
|
||||
}))
|
||||
|
||||
// Import mocked services
|
||||
import { createOrganization, getSetupStatus } from '@/services/organization'
|
||||
|
||||
describe('SetupView', () => {
|
||||
let wrapper: VueWrapper
|
||||
let router: any
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a fresh pinia instance
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Create router instance
|
||||
router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{ path: '/ai-agents', name: 'ai-agents', component: { template: '<div>AI Agents</div>' } }
|
||||
]
|
||||
})
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Initialize router
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
wrapper = mount(SetupView, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for initial organization check
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
it('renders properly', () => {
|
||||
expect(wrapper.find('.setup').exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('欢迎使用 ReplyPier')
|
||||
})
|
||||
|
||||
it('checks for existing organization on mount', async () => {
|
||||
expect(getSetupStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects to ai-agents if organization exists', async () => {
|
||||
// Mock organization exists
|
||||
vi.mocked(getSetupStatus).mockResolvedValueOnce(true)
|
||||
|
||||
// Remount component
|
||||
wrapper = mount(SetupView, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for all promises to resolve and navigation to complete
|
||||
await flushPromises()
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(getSetupStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays form fields with default values', () => {
|
||||
expect(wrapper.find('#orgName').exists()).toBe(true)
|
||||
expect(wrapper.find('#domain').exists()).toBe(true)
|
||||
expect(wrapper.find('#timezone').exists()).toBe(true)
|
||||
expect(wrapper.find('#adminName').exists()).toBe(true)
|
||||
expect(wrapper.find('#adminEmail').exists()).toBe(true)
|
||||
expect(wrapper.find('#adminPassword').exists()).toBe(true)
|
||||
expect(wrapper.find('#confirmPassword').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('validates organization name', async () => {
|
||||
const orgNameInput = wrapper.find('#orgName')
|
||||
|
||||
// Test invalid name
|
||||
await orgNameInput.setValue('a')
|
||||
await orgNameInput.trigger('input')
|
||||
expect(wrapper.find('.error-hint').text()).toContain('组织名称需为')
|
||||
|
||||
// Test valid name
|
||||
await orgNameInput.setValue('Valid Organization')
|
||||
await orgNameInput.trigger('input')
|
||||
expect(wrapper.find('.error-hint').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('validates domain', async () => {
|
||||
const domainInput = wrapper.find('#domain')
|
||||
|
||||
// Test invalid domain
|
||||
await domainInput.setValue('invalid')
|
||||
await domainInput.trigger('input')
|
||||
expect(wrapper.find('.error-hint').text()).toContain('请输入有效的域名')
|
||||
|
||||
// Test valid domain
|
||||
await domainInput.setValue('example.com')
|
||||
await domainInput.trigger('input')
|
||||
expect(wrapper.find('.error-hint').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('validates admin email', async () => {
|
||||
const emailInput = wrapper.find('#adminEmail')
|
||||
|
||||
// Test invalid email
|
||||
await emailInput.setValue('invalid-email')
|
||||
await emailInput.trigger('input')
|
||||
expect(wrapper.find('.error-hint').text()).toContain('请输入有效的邮箱地址')
|
||||
|
||||
// Test valid email
|
||||
await emailInput.setValue('admin@example.com')
|
||||
await emailInput.trigger('input')
|
||||
expect(wrapper.find('.error-hint').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('validates password strength', async () => {
|
||||
const passwordInput = wrapper.find('#adminPassword')
|
||||
|
||||
// Test weak password
|
||||
await passwordInput.setValue('weak')
|
||||
await passwordInput.trigger('input')
|
||||
expect(wrapper.find('.strength-bar').classes()).toContain('weak')
|
||||
|
||||
// Test strong password
|
||||
await passwordInput.setValue('StrongP@ss123')
|
||||
await passwordInput.trigger('input')
|
||||
expect(wrapper.find('.strength-bar').classes()).toContain('strong')
|
||||
})
|
||||
|
||||
it('validates password confirmation', async () => {
|
||||
// Fill in all required fields with valid data first
|
||||
await wrapper.find('#orgName').setValue('Test Organization')
|
||||
await wrapper.find('#domain').setValue('test.com')
|
||||
await wrapper.find('#adminName').setValue('Test Admin')
|
||||
await wrapper.find('#adminEmail').setValue('admin@test.com')
|
||||
|
||||
// Set different passwords
|
||||
const passwordInput = wrapper.find('#adminPassword')
|
||||
const confirmInput = wrapper.find('#confirmPassword')
|
||||
|
||||
await passwordInput.setValue('StrongP@ss123')
|
||||
await passwordInput.trigger('input')
|
||||
await confirmInput.setValue('DifferentP@ss123')
|
||||
|
||||
// Submit form
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.error-message').text()).toBe('两次输入的密码不一致')
|
||||
})
|
||||
|
||||
it('handles timezone selection', async () => {
|
||||
const timezoneSelect = wrapper.find('#timezone')
|
||||
await timezoneSelect.setValue('Europe/London')
|
||||
|
||||
expect((wrapper.vm as any).selectedTimezone).toBe('Europe/London')
|
||||
expect((wrapper.vm as any).orgData.timezone).toBe('Europe/London')
|
||||
})
|
||||
|
||||
it('handles business hours configuration', async () => {
|
||||
// Toggle Saturday to enabled
|
||||
const saturdayToggle = wrapper.findAll('.toggle input')[5]
|
||||
await saturdayToggle.setValue(true)
|
||||
|
||||
// Change Saturday hours
|
||||
const timeSelects = wrapper.findAll('.time-selects select')
|
||||
await timeSelects[10].setValue('10:00') // Start time
|
||||
await timeSelects[11].setValue('18:00') // End time
|
||||
|
||||
const businessHours = (wrapper.vm as any).orgData.business_hours
|
||||
expect(businessHours.saturday.enabled).toBe(true)
|
||||
expect(businessHours.saturday.start).toBe('10:00')
|
||||
expect(businessHours.saturday.end).toBe('18:00')
|
||||
})
|
||||
|
||||
it('submits form with valid data', async () => {
|
||||
// Fill in all required fields with valid data
|
||||
await wrapper.find('#orgName').setValue('Test Organization')
|
||||
await wrapper.find('#domain').setValue('test.com')
|
||||
await wrapper.find('#adminName').setValue('Test Admin')
|
||||
await wrapper.find('#adminEmail').setValue('admin@test.com')
|
||||
await wrapper.find('#adminPassword').setValue('StrongP@ss123')
|
||||
await wrapper.find('#confirmPassword').setValue('StrongP@ss123')
|
||||
|
||||
// Submit form
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await nextTick()
|
||||
|
||||
// Verify organization creation was called
|
||||
expect(createOrganization).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Test Organization',
|
||||
domain: 'test.com',
|
||||
admin_name: 'Test Admin',
|
||||
admin_email: 'admin@test.com',
|
||||
admin_password: 'StrongP@ss123'
|
||||
}))
|
||||
})
|
||||
|
||||
it('handles organization creation error', async () => {
|
||||
const error = 'Failed to create organization'
|
||||
;(createOrganization as any).mockRejectedValueOnce(new Error(error))
|
||||
|
||||
// Fill in required fields
|
||||
await wrapper.find('#orgName').setValue('Test Organization')
|
||||
await wrapper.find('#domain').setValue('test.com')
|
||||
await wrapper.find('#adminName').setValue('Test Admin')
|
||||
await wrapper.find('#adminEmail').setValue('admin@test.com')
|
||||
await wrapper.find('#adminPassword').setValue('StrongP@ss123')
|
||||
await wrapper.find('#confirmPassword').setValue('StrongP@ss123')
|
||||
|
||||
// Submit form
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.error-message').text()).toBe(error)
|
||||
})
|
||||
|
||||
it('shows loading state during submission', async () => {
|
||||
// Mock slow organization creation
|
||||
;(createOrganization as any).mockImplementationOnce(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
|
||||
// Fill in required fields
|
||||
await wrapper.find('#orgName').setValue('Test Organization')
|
||||
await wrapper.find('#domain').setValue('test.com')
|
||||
await wrapper.find('#adminName').setValue('Test Admin')
|
||||
await wrapper.find('#adminEmail').setValue('admin@test.com')
|
||||
await wrapper.find('#adminPassword').setValue('StrongP@ss123')
|
||||
await wrapper.find('#confirmPassword').setValue('StrongP@ss123')
|
||||
|
||||
// Submit form
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').classes()).toContain('loading')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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')">×</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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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">ReplyPier</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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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@replypier.site。')
|
||||
}
|
||||
const onDeleteOrganization = () => {
|
||||
if (window.confirm('永久删除该组织及其全部数据?此操作无法撤销。')) {
|
||||
window.alert('删除组织需由支持团队完成 — 请联系 support@replypier.site。')
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { userService } from '@/services/user'
|
||||
|
||||
const STORAGE_KEY = 'replypier: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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { THEME_COLORS } from '@/config/themeColors'
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'system'
|
||||
|
||||
const STORAGE_KEY = 'rp-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'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 }
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Public product docs. One place, so a docs move is a one-line change.
|
||||
export const DOCS_URL = 'https://docs.replypier.site'
|
||||
export const WIDGET_INSTALL_GUIDE_URL = `${DOCS_URL}/features/widget`
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import './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('rp-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')
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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'
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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')
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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 ReplyPier 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
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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}`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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')
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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)),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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}`)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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()
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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 })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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()
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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 }
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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'
|
||||
>
|
||||
>
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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 {}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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'>
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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[]
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// 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
|
||||
}
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user