From e24d3e2a6548c3579be0c01f1ab1f48c73486f0d Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 24 Sep 2026 21:35:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=90=8E=E5=8F=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E3=80=81=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90=E4=B8=8E?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=AE=BF=E9=97=AE=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。 --- backend/app/api/organizations.py | 724 +++++++++ backend/app/api/people.py | 228 +++ backend/app/api/roles.py | 258 ++++ backend/app/api/token.py | 527 +++++++ backend/app/api/user_groups.py | 139 ++ backend/app/api/users.py | 1214 +++++++++++++++ backend/app/services/auth_sessions.py | 124 ++ backend/app/services/chat_scope_roles.py | 204 +++ backend/app/services/user.py | 131 ++ frontend/src/App.vue | 29 + .../useOrganizationSettings.spec.ts | 66 + .../useUsers.resetPassword.spec.ts | 88 ++ .../src/__tests__/views/LoginView.spec.ts | 256 +++ .../src/__tests__/views/SetupView.spec.ts | 291 ++++ frontend/src/components/common/BaseModal.vue | 192 +++ .../src/components/common/ChannelBadge.vue | 58 + frontend/src/components/common/FileUpload.vue | 795 ++++++++++ .../components/common/GuidanceTextarea.vue | 181 +++ frontend/src/components/common/Modal.vue | 119 ++ .../common/PasswordStrengthMeter.vue | 114 ++ .../src/components/human-agent/GroupForm.vue | 99 ++ .../src/components/human-agent/GroupList.vue | 544 +++++++ .../human-agent/ResetPasswordForm.vue | 181 +++ .../src/components/human-agent/RoleForm.vue | 298 ++++ .../src/components/human-agent/RoleList.vue | 731 +++++++++ .../src/components/human-agent/UserForm.vue | 367 +++++ .../components/human-agent/UserGroupForm.vue | 117 ++ .../src/components/human-agent/UserList.vue | 1181 ++++++++++++++ frontend/src/components/layout/AppSidebar.vue | 352 +++++ frontend/src/components/layout/BottomNav.vue | 156 ++ frontend/src/components/layout/MoreSheet.vue | 394 +++++ .../src/components/layout/SidebarToggle.vue | 64 + frontend/src/components/layout/navIcons.ts | 57 + frontend/src/components/layout/navItems.ts | 197 +++ .../components/organization/Organizations.vue | 869 +++++++++++ .../components/people/PersonDetailDrawer.vue | 409 +++++ frontend/src/components/user/UserSettings.vue | 936 +++++++++++ frontend/src/composables/useAuth.ts | 52 + frontend/src/composables/useBreakpoint.ts | 43 + frontend/src/composables/useCurrency.ts | 101 ++ .../src/composables/useEnterpriseFeatures.ts | 244 +++ frontend/src/composables/useForgotPassword.ts | 231 +++ frontend/src/composables/useGroups.ts | 185 +++ .../src/composables/useOnboardingState.ts | 148 ++ .../composables/useOrganizationSettings.ts | 174 +++ frontend/src/composables/useRoles.ts | 147 ++ frontend/src/composables/useTheme.ts | 79 + frontend/src/composables/useTypewriter.ts | 119 ++ frontend/src/composables/useUnreadBadge.ts | 56 + frontend/src/composables/useUsers.ts | 202 +++ frontend/src/composables/useVisualViewport.ts | 76 + frontend/src/config/api.ts | 168 ++ frontend/src/config/docs.ts | 19 + frontend/src/config/themeColors.ts | 27 + frontend/src/layouts/DashboardLayout.vue | 1374 +++++++++++++++++ frontend/src/main.ts | 64 + frontend/src/router/index.ts | 378 +++++ frontend/src/router/landing.ts | 51 + frontend/src/router/routePermissions.ts | 96 ++ frontend/src/services/agent.ts | 166 ++ frontend/src/services/ai.ts | 46 + frontend/src/services/api.ts | 173 +++ frontend/src/services/auth.ts | 77 + frontend/src/services/channels.ts | 358 +++++ frontend/src/services/chat.ts | 67 + frontend/src/services/crm.ts | 62 + frontend/src/services/faq.ts | 291 ++++ frontend/src/services/firebase.ts | 185 +++ frontend/src/services/groups.ts | 52 + frontend/src/services/jira.ts | 153 ++ frontend/src/services/knowledge.ts | 236 +++ frontend/src/services/leadCapture.ts | 30 + frontend/src/services/mcp.ts | 75 + frontend/src/services/notification.ts | 72 + frontend/src/services/organization.ts | 66 + frontend/src/services/people.ts | 82 + frontend/src/services/roles.ts | 53 + frontend/src/services/sessionToAgent.ts | 25 + frontend/src/services/shopify.ts | 171 ++ frontend/src/services/socket.ts | 122 ++ frontend/src/services/tickets.ts | 265 ++++ frontend/src/services/user.ts | 108 ++ frontend/src/services/users.ts | 137 ++ frontend/src/services/widget-app.ts | 59 + frontend/src/services/widget.ts | 53 + frontend/src/services/workflow.ts | 57 + frontend/src/services/workflowNode.ts | 232 +++ frontend/src/stores/counter.ts | 28 + frontend/src/types/agent.ts | 132 ++ frontend/src/types/ai.ts | 52 + frontend/src/types/chat.ts | 127 ++ frontend/src/types/faq.ts | 169 ++ frontend/src/types/global.d.ts | 52 + frontend/src/types/knowledge.ts | 99 ++ frontend/src/types/leadCapture.ts | 41 + frontend/src/types/mcp.ts | 110 ++ frontend/src/types/organization.ts | 71 + frontend/src/types/people.ts | 80 + frontend/src/types/ticket.ts | 394 +++++ frontend/src/types/timezone-select-js.d.ts | 28 + frontend/src/types/user.ts | 65 + frontend/src/types/widget-app.ts | 46 + frontend/src/types/widget.ts | 112 ++ frontend/src/types/window.d.ts | 30 + frontend/src/types/workflow.ts | 76 + frontend/src/utils/apiError.ts | 38 + frontend/src/utils/avatars.ts | 62 + frontend/src/utils/color.ts | 54 + frontend/src/utils/emitter.ts | 26 + frontend/src/utils/permissionGroups.ts | 69 + frontend/src/utils/permissions.ts | 74 + frontend/src/utils/planLimitToast.ts | 62 + frontend/src/utils/sanitize.ts | 261 ++++ frontend/src/utils/setupEvents.ts | 26 + frontend/src/utils/storage.ts | 391 +++++ frontend/src/utils/text.ts | 40 + frontend/src/utils/timezones.ts | 45 + frontend/src/utils/validators.ts | 72 + frontend/src/views/403.vue | 51 + frontend/src/views/LoginView.vue | 1054 +++++++++++++ frontend/src/views/PeopleView.vue | 360 +++++ frontend/src/views/SetupView.vue | 673 ++++++++ frontend/src/views/UserSettingsView.vue | 26 + 123 files changed, 25293 insertions(+) create mode 100644 backend/app/api/organizations.py create mode 100644 backend/app/api/people.py create mode 100644 backend/app/api/roles.py create mode 100644 backend/app/api/token.py create mode 100644 backend/app/api/user_groups.py create mode 100644 backend/app/api/users.py create mode 100644 backend/app/services/auth_sessions.py create mode 100644 backend/app/services/chat_scope_roles.py create mode 100644 backend/app/services/user.py create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/__tests__/composables/useOrganizationSettings.spec.ts create mode 100644 frontend/src/__tests__/composables/useUsers.resetPassword.spec.ts create mode 100644 frontend/src/__tests__/views/LoginView.spec.ts create mode 100644 frontend/src/__tests__/views/SetupView.spec.ts create mode 100644 frontend/src/components/common/BaseModal.vue create mode 100644 frontend/src/components/common/ChannelBadge.vue create mode 100644 frontend/src/components/common/FileUpload.vue create mode 100644 frontend/src/components/common/GuidanceTextarea.vue create mode 100644 frontend/src/components/common/Modal.vue create mode 100644 frontend/src/components/common/PasswordStrengthMeter.vue create mode 100644 frontend/src/components/human-agent/GroupForm.vue create mode 100644 frontend/src/components/human-agent/GroupList.vue create mode 100644 frontend/src/components/human-agent/ResetPasswordForm.vue create mode 100644 frontend/src/components/human-agent/RoleForm.vue create mode 100644 frontend/src/components/human-agent/RoleList.vue create mode 100644 frontend/src/components/human-agent/UserForm.vue create mode 100644 frontend/src/components/human-agent/UserGroupForm.vue create mode 100644 frontend/src/components/human-agent/UserList.vue create mode 100644 frontend/src/components/layout/AppSidebar.vue create mode 100644 frontend/src/components/layout/BottomNav.vue create mode 100644 frontend/src/components/layout/MoreSheet.vue create mode 100644 frontend/src/components/layout/SidebarToggle.vue create mode 100644 frontend/src/components/layout/navIcons.ts create mode 100644 frontend/src/components/layout/navItems.ts create mode 100644 frontend/src/components/organization/Organizations.vue create mode 100644 frontend/src/components/people/PersonDetailDrawer.vue create mode 100644 frontend/src/components/user/UserSettings.vue create mode 100644 frontend/src/composables/useAuth.ts create mode 100644 frontend/src/composables/useBreakpoint.ts create mode 100644 frontend/src/composables/useCurrency.ts create mode 100644 frontend/src/composables/useEnterpriseFeatures.ts create mode 100644 frontend/src/composables/useForgotPassword.ts create mode 100644 frontend/src/composables/useGroups.ts create mode 100644 frontend/src/composables/useOnboardingState.ts create mode 100644 frontend/src/composables/useOrganizationSettings.ts create mode 100644 frontend/src/composables/useRoles.ts create mode 100644 frontend/src/composables/useTheme.ts create mode 100644 frontend/src/composables/useTypewriter.ts create mode 100644 frontend/src/composables/useUnreadBadge.ts create mode 100644 frontend/src/composables/useUsers.ts create mode 100644 frontend/src/composables/useVisualViewport.ts create mode 100644 frontend/src/config/api.ts create mode 100644 frontend/src/config/docs.ts create mode 100644 frontend/src/config/themeColors.ts create mode 100644 frontend/src/layouts/DashboardLayout.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/router/landing.ts create mode 100644 frontend/src/router/routePermissions.ts create mode 100644 frontend/src/services/agent.ts create mode 100644 frontend/src/services/ai.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/auth.ts create mode 100644 frontend/src/services/channels.ts create mode 100644 frontend/src/services/chat.ts create mode 100644 frontend/src/services/crm.ts create mode 100644 frontend/src/services/faq.ts create mode 100644 frontend/src/services/firebase.ts create mode 100644 frontend/src/services/groups.ts create mode 100644 frontend/src/services/jira.ts create mode 100644 frontend/src/services/knowledge.ts create mode 100644 frontend/src/services/leadCapture.ts create mode 100644 frontend/src/services/mcp.ts create mode 100644 frontend/src/services/notification.ts create mode 100644 frontend/src/services/organization.ts create mode 100644 frontend/src/services/people.ts create mode 100644 frontend/src/services/roles.ts create mode 100644 frontend/src/services/sessionToAgent.ts create mode 100644 frontend/src/services/shopify.ts create mode 100644 frontend/src/services/socket.ts create mode 100644 frontend/src/services/tickets.ts create mode 100644 frontend/src/services/user.ts create mode 100644 frontend/src/services/users.ts create mode 100644 frontend/src/services/widget-app.ts create mode 100644 frontend/src/services/widget.ts create mode 100644 frontend/src/services/workflow.ts create mode 100644 frontend/src/services/workflowNode.ts create mode 100644 frontend/src/stores/counter.ts create mode 100644 frontend/src/types/agent.ts create mode 100644 frontend/src/types/ai.ts create mode 100644 frontend/src/types/chat.ts create mode 100644 frontend/src/types/faq.ts create mode 100644 frontend/src/types/global.d.ts create mode 100644 frontend/src/types/knowledge.ts create mode 100644 frontend/src/types/leadCapture.ts create mode 100644 frontend/src/types/mcp.ts create mode 100644 frontend/src/types/organization.ts create mode 100644 frontend/src/types/people.ts create mode 100644 frontend/src/types/ticket.ts create mode 100644 frontend/src/types/timezone-select-js.d.ts create mode 100644 frontend/src/types/user.ts create mode 100644 frontend/src/types/widget-app.ts create mode 100644 frontend/src/types/widget.ts create mode 100644 frontend/src/types/window.d.ts create mode 100644 frontend/src/types/workflow.ts create mode 100644 frontend/src/utils/apiError.ts create mode 100644 frontend/src/utils/avatars.ts create mode 100644 frontend/src/utils/color.ts create mode 100644 frontend/src/utils/emitter.ts create mode 100644 frontend/src/utils/permissionGroups.ts create mode 100644 frontend/src/utils/permissions.ts create mode 100644 frontend/src/utils/planLimitToast.ts create mode 100644 frontend/src/utils/sanitize.ts create mode 100644 frontend/src/utils/setupEvents.ts create mode 100644 frontend/src/utils/storage.ts create mode 100644 frontend/src/utils/text.ts create mode 100644 frontend/src/utils/timezones.ts create mode 100644 frontend/src/utils/validators.ts create mode 100644 frontend/src/views/403.vue create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/PeopleView.vue create mode 100644 frontend/src/views/SetupView.vue create mode 100644 frontend/src/views/UserSettingsView.vue diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py new file mode 100644 index 0000000..0c8388a --- /dev/null +++ b/backend/app/api/organizations.py @@ -0,0 +1,724 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +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, + } diff --git a/backend/app/api/people.py b/backend/app/api/people.py new file mode 100644 index 0000000..699b584 --- /dev/null +++ b/backend/app/api/people.py @@ -0,0 +1,228 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +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) diff --git a/backend/app/api/roles.py b/backend/app/api/roles.py new file mode 100644 index 0000000..ca9c767 --- /dev/null +++ b/backend/app/api/roles.py @@ -0,0 +1,258 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +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() diff --git a/backend/app/api/token.py b/backend/app/api/token.py new file mode 100644 index 0000000..7fde044 --- /dev/null +++ b/backend/app/api/token.py @@ -0,0 +1,527 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +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 ` + + 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" + ) + diff --git a/backend/app/api/user_groups.py b/backend/app/api/user_groups.py new file mode 100644 index 0000000..e27a71a --- /dev/null +++ b/backend/app/api/user_groups.py @@ -0,0 +1,139 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +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"} \ No newline at end of file diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..d92eb86 --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,1214 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from fastapi import APIRouter, Depends, HTTPException, Query, status, Response, Request, Body, File, UploadFile +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.security import HTTPBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from typing import List, Optional +import json +import os +from urllib.parse import quote +from sqlalchemy.sql import func +from uuid import UUID + +from app.database import get_db +from app.models.user import User +from app.models.session_to_agent import SessionToAgent, SessionStatus +from app.models.schemas.user import AdminPasswordReset, TeammateResponse, UserCreate, UserStatusUpdate, UserUpdate, UserResponse, TokenResponse +from datetime import datetime, timezone +from app.core.security import validate_password_strength +from app.core.auth import INBOX_PERMISSIONS, get_current_user, request_token, require_any_permission, require_permissions +from app.services.auth_sessions import ( + REFRESH, issue_tokens, live_payload, revoke_session, revoke_user_sessions, session_id_of, +) +from app.core.logger import get_logger +from app.repositories.user import UserRepository +from app.repositories.fcm_token import FCMTokenRepository +from pydantic import BaseModel +from app.models.role import Role +from app.services.chat_scope_roles import resolve_role +from app.core.s3 import get_s3_signed_url, sign_s3_url, upload_file_to_s3, delete_file_from_s3 +from app.core.config import settings +from app.services.file_storage import local_upload_path +from app.repositories.shopify_shop_repository import ShopifyShopRepository +from app.models.schemas.shopify.shopify_shop import ShopifyShopUpdate +from app.core.cors import update_cors_middleware +from app.core.application import app +from app.core.params import MAX_DB_INT +# Try to import enterprise modules +try: + from app.enterprise.repositories.subscription import SubscriptionRepository + 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 + +# 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 + +logger = get_logger(__name__) +router = APIRouter( + tags=["users"] +) + +security = HTTPBearer() + +UPLOAD_DIR = "uploads/user" +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif"} +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB + + +def _require_role_in_org(db, role_id, org_id) -> Optional[Role]: + """A user may only be given a role defined in their own organization. + + Role ids are sequential integers, so without this an admin could bind a + user to another tenant's role — inheriting that role's permissions and + breaking the other org's "is this role in use" accounting. + """ + if role_id is None: + return None + role = db.query(Role).filter(Role.id == role_id).first() + if not role or role.organization_id != org_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + return role + +def get_file_extension(filename: str) -> str: + return os.path.splitext(filename)[1].lower() + +async def save_upload_file(file: UploadFile, org_id: str, user_id: str) -> str: + """Save uploaded file and return the file path""" + # Generate unique filename + file_extension = get_file_extension(file.filename) + filename = f"profile{file_extension}" + + if settings.S3_FILE_STORAGE: + folder = f"users/{org_id}/{user_id}" + content = await file.read() + return await upload_file_to_s3(content, folder, filename, content_type=file.content_type) + else: + # Local storage + # Create upload directory if it doesn't exist + user_upload_dir = os.path.join(UPLOAD_DIR, org_id, user_id) + os.makedirs(user_upload_dir, exist_ok=True) + + file_path = os.path.join(user_upload_dir, filename) + + # Save file + with open(file_path, "wb") as buffer: + content = await file.read() + buffer.write(content) + + # Prefixed to match the {API_V1_STR}/uploads static mount, as store_upload does. + return f"{settings.API_V1_STR}/uploads/user/{org_id}/{user_id}/{filename}" + +@router.post("", response_model=UserResponse) +async def create_user( + user_data: UserCreate, + current_user: User = Depends(require_permissions("manage_users")), + db: Session = Depends(get_db) +): + """Create a new user""" + try: + if HAS_EMAIL_VALIDATION: + ensure_not_disposable(user_data.email) + + user_repo = UserRepository(db) + + # Check if email already exists + if user_repo.get_user_by_email(user_data.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Check enterprise subscription limits if enterprise module is available + if HAS_ENTERPRISE: + # Accessible = active/trial/past-due-in-period OR cancelled-but- + # still-in-paid-period; raises 403 when the org has no plan. + subscription = require_accessible_subscription(db, current_user.organization_id) + + # Check if max_users feature exists in plan + plan_repo = PlanRepository(db) + if plan_repo.check_feature_availability(subscription.plan_id, 'max_users'): + # Get current active users count + active_users = user_repo.get_active_users_count(str(current_user.organization_id)) + + # Check against subscription quantity (user seats) + if subscription.quantity is not None and active_users >= subscription.quantity: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Maximum number of users ({subscription.quantity}) reached for your plan. Please upgrade your plan to add more users." + ) + + # The role must belong to the caller's own organization. + role = _require_role_in_org(db, user_data.role_id, current_user.organization_id) + + # The chat-scope toggles may point at a different role than the one + # picked in the dropdown. Returns the same role when they match it. + role = resolve_role( + db, current_user, role, + user_data.see_all_ai_chats, user_data.see_all_org_chats, + ) + + # Hash the password + hashed_password = User.get_password_hash(user_data.password) + + # Create user with organization from current user + new_user = user_repo.create_user( + email=user_data.email, + full_name=user_data.full_name, + hashed_password=hashed_password, + organization_id=current_user.organization_id, + is_active=user_data.is_active, + role_id=role.id + ) + + return new_user.to_dict() + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating user: {str(e)}") + if "duplicate key value violates unique constraint" in str(e): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.get("", response_model=List[UserResponse]) +async def list_users( + skip: int = Query(0, ge=0, le=MAX_DB_INT), + limit: int = Query(100, ge=1, le=1000), + current_user: User = Depends(require_permissions("manage_users")), + db: Session = Depends(get_db) +): + """List all users in the organization""" + try: + user_repo = UserRepository(db) + users = user_repo.get_users_by_organization(current_user.organization_id) + + # profile_pic is signed by UserResponse on serialization — assigning the + # signed value onto the ORM object would persist it on the next flush. + + return users + except Exception as e: + logger.error(f"Error listing users: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +# Default concurrent-chat capacity for an agent (no per-user column yet). +DEFAULT_AGENT_CAPACITY = 5 + + +class TeamAgentStats(BaseModel): + id: str + full_name: str + email: str + profile_pic: Optional[str] = None + is_online: bool = False + last_seen: Optional[datetime] = None + is_active: bool = True + role: Optional[str] = None + is_admin: bool = False + groups: List[str] = [] + active_chats: int = 0 + resolved_chats: int = 0 + capacity: int = DEFAULT_AGENT_CAPACITY + + +class TeamKpis(BaseModel): + team_size: int = 0 + admins: int = 0 + agents: int = 0 + online_now: int = 0 + active_chats: int = 0 + total_capacity: int = 0 + waiting_handoff: int = 0 + oldest_wait_minutes: int = 0 + + +class TeamOverviewResponse(BaseModel): + kpis: TeamKpis + agents: List[TeamAgentStats] + + +@router.get("/team-overview", response_model=TeamOverviewResponse) +async def get_team_overview( + current_user: User = Depends(require_permissions("manage_users")), + db: Session = Depends(get_db) +): + """Aggregated Human-Agents dashboard: per-agent load/resolved counts + org KPIs.""" + try: + org_id = current_user.organization_id + user_repo = UserRepository(db) + users = user_repo.get_users_by_organization(org_id) + + # Per-agent active (OPEN, assigned to a human) chat counts + active_rows = ( + db.query(SessionToAgent.user_id, func.count(SessionToAgent.session_id)) + .filter( + SessionToAgent.organization_id == org_id, + SessionToAgent.status == SessionStatus.OPEN, + SessionToAgent.user_id.isnot(None), + ) + .group_by(SessionToAgent.user_id) + .all() + ) + active_counts = {str(uid): cnt for uid, cnt in active_rows} + + # Per-agent resolved (CLOSED) chat counts + resolved_rows = ( + db.query(SessionToAgent.user_id, func.count(SessionToAgent.session_id)) + .filter( + SessionToAgent.organization_id == org_id, + SessionToAgent.status == SessionStatus.CLOSED, + SessionToAgent.user_id.isnot(None), + ) + .group_by(SessionToAgent.user_id) + .all() + ) + resolved_counts = {str(uid): cnt for uid, cnt in resolved_rows} + + # Org-wide waiting handoffs (transferred, not yet picked up by a human) + waiting_handoff = ( + db.query(func.count(SessionToAgent.session_id)) + .filter( + SessionToAgent.organization_id == org_id, + SessionToAgent.status == SessionStatus.TRANSFERRED, + SessionToAgent.user_id.is_(None), + ) + .scalar() + or 0 + ) + oldest_wait = ( + db.query(func.min(SessionToAgent.assigned_at)) + .filter( + SessionToAgent.organization_id == org_id, + SessionToAgent.status == SessionStatus.TRANSFERRED, + SessionToAgent.user_id.is_(None), + ) + .scalar() + ) + oldest_wait_minutes = 0 + if oldest_wait is not None: + if oldest_wait.tzinfo is None: + oldest_wait = oldest_wait.replace(tzinfo=timezone.utc) + oldest_wait_minutes = max( + 0, int((datetime.now(timezone.utc) - oldest_wait).total_seconds() // 60) + ) + + agents: List[TeamAgentStats] = [] + admins = 0 + online_now = 0 + total_active = 0 + total_capacity = 0 + for user in users: + uid = str(user.id) + role_name = user.role.name if user.role else None + perms = {p.name for p in (user.role.permissions or [])} if user.role else set() + is_admin = bool(perms & {"manage_organization", "super_admin"}) or (role_name == "Admin") + if is_admin: + admins += 1 + if user.is_online: + online_now += 1 + + active = active_counts.get(uid, 0) + total_active += active + total_capacity += DEFAULT_AGENT_CAPACITY + + profile_pic = user.profile_pic + if settings.S3_FILE_STORAGE and profile_pic: + try: + profile_pic = await get_s3_signed_url(profile_pic) + except Exception: + pass + + agents.append(TeamAgentStats( + id=uid, + full_name=user.full_name, + email=user.email, + profile_pic=profile_pic, + is_online=bool(user.is_online), + last_seen=user.last_seen, + is_active=bool(user.is_active), + role=role_name, + is_admin=is_admin, + groups=[g.name for g in (user.groups or [])], + active_chats=active, + resolved_chats=resolved_counts.get(uid, 0), + capacity=DEFAULT_AGENT_CAPACITY, + )) + + kpis = TeamKpis( + team_size=len(users), + admins=admins, + agents=len(users) - admins, + online_now=online_now, + active_chats=total_active, + total_capacity=total_capacity, + waiting_handoff=int(waiting_handoff), + oldest_wait_minutes=oldest_wait_minutes, + ) + return TeamOverviewResponse(kpis=kpis, agents=agents) + except Exception as e: + logger.error(f"Error building team overview: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get("/teammates", response_model=List[TeammateResponse]) +async def list_teammates( + current_user: User = Depends(require_any_permission(*INBOX_PERMISSIONS)), + db: Session = Depends(get_db) +): + """The colleagues an inbox user can hand a conversation to. + + Declared above GET /{user_id}: below it, "teammates" parses as a user id + and the request lands on the manage_users route — a 403 for exactly the + agents this exists for. + + Deliberately not a relaxation of GET /users, which returns each teammate's + role and its full permission list. Reassigning a chat needs a name and a + face, so that is all this returns. + """ + users = UserRepository(db).get_users_by_organization(current_user.organization_id) + return [user for user in users if user.is_active] + + +@router.get("/me/avatar") +async def get_my_avatar(current_user: User = Depends(get_current_user)): + """Redirect to the caller's profile picture, signed at request time. + + The dashboard used to render the URL held in the cached `user_info` blob. + That blob is written at login and never rewritten, but the URL inside it is + an S3 presigned link good for S3_PRESIGN_EXPIRY_SECONDS — an hour by + default. Every session outlasted its own avatar, and the only way to get a + working one back was to log out and in again. + + A stable path fixes that for good: the browser asks us, and whatever it + gets handed is freshly signed. no-store because the *redirect* must not be + cached — the object it points at still can be. + + Declared above GET /{user_id}, or "me" parses as a user id. + """ + if not current_user.profile_pic: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No profile picture") + + location = sign_s3_url(current_user.profile_pic) + if not location.startswith(("http://", "https://")): + # Local storage is served under the API prefix, which is what + # save_upload_file now stores. Rows written before that normalisation + # carry a bare "/uploads/..." and would 404, so prefix those too — + # same tolerance local_upload_path applies on the way back down. + path = location.lstrip("/") + if path.startswith("uploads/"): + path = f"{settings.API_V1_STR.strip('/')}/{path}" + location = f"/{path}" + + return RedirectResponse( + url=location, + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: UUID, + current_user: User = Depends(require_permissions("manage_users")), + db: Session = Depends(get_db) +): + """Get user by ID""" + user_repo = UserRepository(db) + user = user_repo.get_user(user_id) + + if not user or user.organization_id != current_user.organization_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + return user.to_dict() + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: str, + current_user: User = Depends(require_permissions("manage_users")), + db: Session = Depends(get_db) +): + """Delete a user""" + try: + user_repo = UserRepository(db) + user = user_repo.get_user(user_id) + + if not user or user.organization_id != current_user.organization_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + user_repo.delete_user(user_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete user: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete user" + ) + + +@router.post("/login", response_model=TokenResponse) +async def login( + response: Response, + request: Request, + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db) +): + """Authenticate user and set cookies""" + try: + # Verify credentials + user = db.query(User).filter( + User.email == form_data.username, + User.is_active == True + ).first() + + if not user or not user.verify_password(form_data.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Update online status and open a session for this sign-in + user.is_online = True + user.last_seen = func.now() + access_token, refresh_token = issue_tokens(db, user.id, user.organization_id) + db.commit() + + # Get role info + role = db.query(Role).filter(Role.id == user.role_id).first() + + # Set secure cookies + 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 + user_info = json.dumps({ + "id": str(user.id), + "email": user.email, + "full_name": user.full_name, + "organization_id": str(user.organization_id), + "role": role.to_dict() if role else None + }, default=str) + response.set_cookie( + key="user_info", + value=quote(user_info), # URL encode the JSON string + samesite="none", # Changed to "none" for cross-domain support (shopifiy) + secure=True, # Required when samesite="none" + max_age=604800 # 7 days + ) + + # Handle Shopify shop update if shop_id is provided in query params + shop_id = request.query_params.get('shop_id') + if shop_id: + try: + shop_repository = ShopifyShopRepository(db) + db_shop = shop_repository.get_shop(shop_id) + + if db_shop: + # Update shop with user's organization_id + shop_update = ShopifyShopUpdate( + organization_id=str(user.organization_id) + ) + shop_repository.update_shop(db_shop.id, shop_update) + + # Update organization domain with shop domain + try: + from app.repositories.organization import OrganizationRepository + org_repo = OrganizationRepository(db) + organization = org_repo.get_organization(str(user.organization_id)) + + if organization and db_shop.shop_domain: + # Only update if domain is different + if organization.domain != db_shop.shop_domain: + # Check if domain is already used by another organization + existing_org = org_repo.get_organization_by_domain(db_shop.shop_domain) + if not existing_org or str(existing_org.id) == str(organization.id): + # Update organization domain to shop domain + organization.domain = db_shop.shop_domain + db.add(organization) + logger.info(f"Updated organization {user.organization_id} domain to {db_shop.shop_domain}") + else: + logger.warning(f"Domain {db_shop.shop_domain} is already used by organization {existing_org.id}, skipping update") + except Exception as domain_error: + logger.error(f"Error updating organization domain during login: {str(domain_error)}") + # Don't fail login if domain update fails + + db.commit() + + # Update CORS middleware to include new domain + try: + update_cors_middleware(app) + logger.info("Updated CORS middleware after linking shop during login") + except Exception as cors_error: + logger.error(f"Error updating CORS middleware: {str(cors_error)}") + # Don't fail login if CORS update fails + + logger.info(f"Updated shop {db_shop.shop_domain} with organization_id {user.organization_id} after login") + else: + logger.warning(f"Shop {shop_id} not found during login") + except Exception as e: + logger.error(f"Error updating shopify shop during login: {str(e)}") + # Don't fail login if shop update fails + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "user": { + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "organization_id": user.organization_id, + "profile_pic": await get_s3_signed_url(user.profile_pic) if settings.S3_FILE_STORAGE and user.profile_pic else user.profile_pic, + "is_online": user.is_online, + "last_seen": user.last_seen, + "is_active": user.is_active, + "role": role.to_dict() if role else None + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Login failed: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Login failed. Please try again later." + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + response: Response, + request: Request, # Add this to access cookies + db: Session = Depends(get_db) +): + """Get new access token using refresh token from cookie""" + try: + # Get refresh token from cookie + refresh_token = request.cookies.get("refresh_token") + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token missing", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = live_payload(db, refresh_token, REFRESH) + + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id = payload.get("sub") + org_id = payload.get("org") + + if not user_id or not org_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Convert string UUID to UUID object + try: + user_id = UUID(user_id) + org_id = UUID(org_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload format", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Verify user still exists and is active + user = db.query(User).filter(User.id == user_id).first() + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Update last seen, and re-mint tokens for the same session (a legacy + # token without one gets a new session) + user.last_seen = func.now() + access_token, refresh_token = issue_tokens(db, user_id, org_id, sid=payload.get("sid")) + db.commit() + + role = db.query(Role).filter(Role.id == user.role_id).first() + + # Set secure cookies + 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 + user_info = json.dumps({ + "id": str(user.id), + "email": user.email, + "full_name": user.full_name, + "organization_id": str(user.organization_id), + "role": role.to_dict() if role else None + }, default=str) + response.set_cookie( + key="user_info", + value=quote(user_info), # URL encode the JSON string + samesite="none", # Changed to "none" for cross-domain support (shopifiy) + secure=True, # Required when samesite="none" + max_age=604800 # 7 days + ) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "user": { + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "organization_id": user.organization_id, + "profile_pic": await get_s3_signed_url(user.profile_pic) if settings.S3_FILE_STORAGE and user.profile_pic else user.profile_pic, + "is_online": user.is_online, + "last_seen": user.last_seen, + "is_active": user.is_active, + "role": role.to_dict() if role else None + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Token refresh failed: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Token refresh failed. Please try again later." + ) + + +@router.post("/logout") +async def logout( + response: Response, + request: Request, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Logout user, end the session and clear cookies""" + # End the session server-side, so a copy of either token stops working + for token in (request_token(request), request.cookies.get("refresh_token")): + sid = session_id_of(token) + if sid: + revoke_session(db, sid) + + # Update online status (commits the revocation too) + user_repo = UserRepository(db) + user_repo.update_user(current_user.id, is_online=False, last_seen=func.now()) + + # Clear cookies + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + response.delete_cookie("user_info") + + return {"message": "Successfully logged out"} + + +class FCMTokenUpdate(BaseModel): + token: str + + +@router.post("/token/fcm-token") +async def register_fcm_token( + token_data: FCMTokenUpdate = Body(...), + current_user: User = Depends(get_current_user), + db=Depends(get_db) +): + """Register this browser's web-push token for the signed-in user.""" + try: + token_repo = FCMTokenRepository(db) + success = token_repo.register(current_user.id, token_data.token) + + if success: + return {"message": "FCM token registered successfully"} + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to register FCM token" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error registering FCM token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.delete("/token/fcm-token") +async def unregister_fcm_token( + token_data: FCMTokenUpdate = Body(...), + current_user: User = Depends(get_current_user), + db=Depends(get_db) +): + """Drop this browser's token on logout. + + The token is required: clearing every token for the account (what this used + to do) meant signing out on a laptop silently killed push on the user's + phone. A device that can no longer produce its token is cleaned up instead + by the dead-token pruning in send_fcm_notification. + """ + try: + token_repo = FCMTokenRepository(db) + token_repo.remove(current_user.id, token_data.token) + # Absent rows are not an error: logging out twice, or after the token + # was already pruned, should still succeed. + return {"message": "FCM token cleared successfully"} + + except Exception as e: + logger.error(f"Error clearing FCM token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.patch("/me", response_model=UserResponse) +async def update_profile( + data: UserUpdate, + request: Request, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update current user's profile""" + try: + user_repo = UserRepository(db) + + # Remove role_id from update data to prevent role modification + if hasattr(data, 'role_id'): + delattr(data, 'role_id') + + # If updating email, check it's a real mailbox and not already taken. + # Same bounce risk as signup, and otherwise a trivial way around that gate. + if data.email and data.email != current_user.email: + if HAS_EMAIL_VALIDATION: + ensure_not_disposable(data.email) + existing_user = user_repo.get_user_by_email(data.email) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + fields = data.dict(exclude_unset=True) + # `password` and `current_password` are request-only fields; the column + # is `hashed_password`. Passing `password` through wrote a stray + # attribute onto the ORM object that was never persisted, so changing + # your own password quietly did nothing while returning 200. + new_password = fields.pop('password', None) + current_password = fields.pop('current_password', None) + + if new_password: + if not current_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is required" + ) + if not current_user.verify_password(current_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect current password" + ) + # Same bar an admin-set password has to clear — otherwise the + # policy is one self-service change away from being bypassed. + try: + validate_password_strength(new_password) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + fields['hashed_password'] = User.get_password_hash(new_password) + # Sign out everywhere else; the session making the change stays. + revoke_user_sessions( + db, current_user.id, keep_sid=session_id_of(request_token(request)) + ) + + updated_user = user_repo.update_user( + current_user.id, + **fields + ) + # Generate signed URL if using S3 and user has a profile picture + if settings.S3_FILE_STORAGE and updated_user.profile_pic: + signed_url = await get_s3_signed_url(updated_user.profile_pic) + # Create a response with both the user data and the signed URL + user_dict = updated_user.to_dict() + user_dict["profile_pic"] = signed_url + return user_dict + + return updated_user + + except HTTPException: + raise + except Exception as e: + logger.error(f"Profile update failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update profile" + ) + + +@router.put("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: str, + user_data: UserUpdate, + current_user: User = Depends(require_permissions("manage_users")), + db: Session = Depends(get_db) +): + """Update a user""" + user_repo = UserRepository(db) + user = user_repo.get_user(user_id) + + if not user or user.organization_id != current_user.organization_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + # A supplied role must belong to the caller's org, not another tenant's. + role = _require_role_in_org(db, user_data.role_id, current_user.organization_id) + + # Check if updating email and if it's already taken + if user_data.email and user_data.email != user.email: + if HAS_EMAIL_VALIDATION: + ensure_not_disposable(user_data.email) + existing_user = user_repo.get_user_by_email(user_data.email) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + + if HAS_ENTERPRISE and hasattr(user_data, 'is_active') and user_data.is_active != user.is_active: + # Accessible sub (incl. cancelled-but-still-in-paid-period), or None. + subscription = SubscriptionRepository(db).get_active_subscription(str(user.organization_id)) + # When activating a user, enforce the plan's seat limit. A returned + # subscription is always currently accessible, so no is_active recheck. + if subscription and user_data.is_active: + plan_repo = PlanRepository(db) + if plan_repo.check_feature_availability(subscription.plan_id, 'max_users'): + active_users = user_repo.get_active_users_count(str(user.organization_id)) + if subscription.quantity is not None and active_users >= subscription.quantity: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Cannot activate user: Maximum number of users ({subscription.quantity}) reached for your plan. Please upgrade your plan to add more users." + ) + + try: + fields = user_data.dict(exclude_unset=True) + # A password sent here used to be written straight onto the ORM object as + # a `password` attribute the User model does not have — silently doing + # nothing. Resetting someone else's password has its own endpoint below. + fields.pop('password', None) + fields.pop('current_password', None) + + # The toggles describe a chat scope, not a column. They resolve against + # whichever role the user is ending up with — the one just picked, or + # the one they already had when the dropdown was left alone. + see_all_ai_chats = fields.pop('see_all_ai_chats', None) + see_all_org_chats = fields.pop('see_all_org_chats', None) + base_role = role or user.role + if base_role is not None: + # Assigned unconditionally, not only when it differs from the role + # in the payload: the toggles can resolve back to a role the person + # is already on, and letting the dropdown's id win there would + # silently narrow them. + fields['role_id'] = resolve_role( + db, current_user, base_role, see_all_ai_chats, see_all_org_chats + ).id + + if fields.get('is_active') is False: + # Already refused at every auth check; ending the sessions too + # means reactivating the user does not bring them back. + revoke_user_sessions(db, user.id) + + updated_user = user_repo.update_user(user_id, **fields) + + # Generate signed URL if using S3 and user has a profile picture + if settings.S3_FILE_STORAGE and updated_user.profile_pic: + signed_url = await get_s3_signed_url(updated_user.profile_pic) + # Create a response with both the user data and the signed URL + user_dict = updated_user.to_dict() + user_dict["profile_pic"] = signed_url + return user_dict + + return updated_user + except Exception as e: + logger.error(f"Failed to update user: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update user" + ) + + +@router.post("/{user_id}/reset-password") +async def reset_user_password( + user_id: str, + data: AdminPasswordReset, + current_user: User = Depends(require_permissions("manage_users")), + db: Session = Depends(get_db) +): + """Set a new password for another user in the caller's organization. + + For the common case of an agent locked out of their account: the admin sets + a password and hands it over, rather than the agent going through the + email OTP flow (which the community edition does not ship). + """ + user_repo = UserRepository(db) + user = user_repo.get_user(user_id) + + if not user or user.organization_id != current_user.organization_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + # Changing your own password still goes through /users/me, which proves you + # know the current one. Allowing it here would let anyone holding a stolen + # admin session lock the real admin out without ever learning their password. + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Use your profile settings to change your own password" + ) + + try: + # Whoever holds the old password may already be signed in; end that. + revoke_user_sessions(db, user.id) + user_repo.update_user(user_id, hashed_password=User.get_password_hash(data.new_password)) + except Exception as e: + logger.error(f"Failed to reset password for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to reset password" + ) + + logger.info(f"User {current_user.id} reset the password of user {user_id}") + return {"message": "Password reset successfully"} + + +@router.post("/{user_id}/status") +async def update_user_status( + user_id: str, + status_data: UserStatusUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update user's online status""" + try: + user_repo = UserRepository(db) + user = user_repo.get_user(user_id) + + if not user or user.id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Can only update own status" + ) + + user_repo.update_user( + user_id, + is_online=status_data.is_online, + last_seen=func.now() + ) + + # Get updated user to return current last_seen + updated_user = user_repo.get_user(user_id) + return { + "message": "Status updated successfully", + "is_online": updated_user.is_online, + "last_seen": updated_user.last_seen + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Status update failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update status" + ) + + +@router.post("/me/profile-pic", response_model=UserResponse) +async def upload_profile_pic( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Upload user profile picture""" + try: + # Validate file extension + file_ext = get_file_extension(file.filename) + if file_ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}" + ) + + # Validate file size + file_size = len(await file.read()) + await file.seek(0) # Reset file pointer + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File size too large. Maximum size: {MAX_FILE_SIZE/1024/1024}MB" + ) + + # Delete old profile picture if it exists + if current_user.profile_pic: + if settings.S3_FILE_STORAGE: + await delete_file_from_s3(current_user.profile_pic) + else: + old_photo_path = local_upload_path(current_user.profile_pic) + if os.path.exists(old_photo_path): + os.remove(old_photo_path) + + # Save file and update user + file_path = await save_upload_file( + file, + str(current_user.organization_id), + str(current_user.id) + ) + + user_repo = UserRepository(db) + updated_user = user_repo.update_user( + current_user.id, + profile_pic=file_path + ) + + # Generate signed URL if using S3 + if settings.S3_FILE_STORAGE and updated_user.profile_pic: + signed_url = await get_s3_signed_url(updated_user.profile_pic) + # Create a response with both the user data and the signed URL + user_dict = updated_user.to_dict() + user_dict["profile_pic"] = signed_url + return user_dict + + return updated_user + + except HTTPException: + raise + except Exception as e: + logger.error(f"Profile picture upload failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload profile picture" + ) + + +@router.delete("/me/profile-pic", response_model=UserResponse) +async def delete_profile_pic( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Delete user profile picture""" + try: + if current_user.profile_pic: + if settings.S3_FILE_STORAGE: + await delete_file_from_s3(current_user.profile_pic) + else: + old_photo_path = local_upload_path(current_user.profile_pic) + if os.path.exists(old_photo_path): + os.remove(old_photo_path) + + user_repo = UserRepository(db) + updated_user = user_repo.update_user( + current_user.id, + profile_pic=None + ) + + # Return user data with profile_pic_url set to None for consistency + if settings.S3_FILE_STORAGE: + user_dict = updated_user.to_dict() + user_dict["profile_pic"] = None + return user_dict + + return updated_user + + except Exception as e: + logger.error(f"Profile picture deletion failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete profile picture" + ) + + + diff --git a/backend/app/services/auth_sessions.py b/backend/app/services/auth_sessions.py new file mode 100644 index 0000000..4c4d458 --- /dev/null +++ b/backend/app/services/auth_sessions.py @@ -0,0 +1,124 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +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 diff --git a/backend/app/services/chat_scope_roles.py b/backend/app/services/chat_scope_roles.py new file mode 100644 index 0000000..0d8a3ac --- /dev/null +++ b/backend/app/services/chat_scope_roles.py @@ -0,0 +1,204 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +"""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}" diff --git a/backend/app/services/user.py b/backend/app/services/user.py new file mode 100644 index 0000000..dba4926 --- /dev/null +++ b/backend/app/services/user.py @@ -0,0 +1,131 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import 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=; + # 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)}") diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..ccc513d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,29 @@ + + + + + + + diff --git a/frontend/src/__tests__/composables/useOrganizationSettings.spec.ts b/frontend/src/__tests__/composables/useOrganizationSettings.spec.ts new file mode 100644 index 0000000..ba4b00d --- /dev/null +++ b/frontend/src/__tests__/composables/useOrganizationSettings.spec.ts @@ -0,0 +1,66 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' + +const updateOrganization = vi.fn() + +vi.mock('@/composables/useAuth', () => ({ + useAuth: () => ({ user: ref({ organization_id: 'org-1' }) }), +})) + +vi.mock('@/services/organization', () => ({ + organizationService: { + getOrganization: vi.fn(), + getOrganizationStats: vi.fn(), + updateOrganization: (...args: unknown[]) => updateOrganization(...args), + }, +})) + +const { useOrganizationSettings } = await import('@/composables/useOrganizationSettings') + +describe('useOrganizationSettings – save errors', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the API detail when the domain is already taken', async () => { + updateOrganization.mockRejectedValueOnce({ + message: 'Request failed with status code 409', + response: { status: 409, data: { detail: 'That domain is already used by another organization.' } }, + }) + const settings = useOrganizationSettings() + settings.formData.value.domain = 'localhost' + + await settings.updateOrganization() + + expect(updateOrganization).toHaveBeenCalledWith('org-1', expect.objectContaining({ domain: 'localhost' })) + expect(settings.error.value).toBe('That domain is already used by another organization.') + expect(settings.message.value).toBe('') + expect(settings.hasChanges.value).toBe(true) + }) + + it('falls back to a generic message when there is no API detail', async () => { + updateOrganization.mockRejectedValueOnce({}) + const settings = useOrganizationSettings() + settings.formData.value.name = 'Renamed' + + await settings.updateOrganization() + + expect(settings.error.value).toBe('更新组织信息失败') + }) +}) diff --git a/frontend/src/__tests__/composables/useUsers.resetPassword.spec.ts b/frontend/src/__tests__/composables/useUsers.resetPassword.spec.ts new file mode 100644 index 0000000..14aa781 --- /dev/null +++ b/frontend/src/__tests__/composables/useUsers.resetPassword.spec.ts @@ -0,0 +1,88 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { User } from '@/types/user' + +const resetUserPassword = vi.fn() +const toastSuccess = vi.fn() +const toastError = vi.fn() + +vi.mock('@/services/users', () => ({ + listUsers: vi.fn(), + createUser: vi.fn(), + updateUser: vi.fn(), + deleteUser: vi.fn(), + resetUserPassword: (...args: unknown[]) => resetUserPassword(...args), +})) + +vi.mock('vue-sonner', () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})) + +const { useUsers } = await import('@/composables/useUsers') + +const agent = { id: 'agent-1', full_name: 'Agent One', email: 'agent@test.com' } as User + +describe('useUsers – admin password reset', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the modal for the selected user', () => { + const users = useUsers() + users.handleResetPassword(agent) + + expect(users.showResetPasswordModal.value).toBe(true) + expect(users.selectedUser.value).toEqual(agent) + }) + + it('resets the password and closes the modal', async () => { + resetUserPassword.mockResolvedValueOnce(undefined) + const users = useUsers() + users.handleResetPassword(agent) + + await users.confirmResetPassword('NewPassw0rd!') + + expect(resetUserPassword).toHaveBeenCalledWith('agent-1', 'NewPassw0rd!') + expect(users.showResetPasswordModal.value).toBe(false) + expect(users.selectedUser.value).toBeNull() + expect(toastSuccess).toHaveBeenCalled() + expect(users.resettingPassword.value).toBe(false) + }) + + it('keeps the modal open and surfaces the API message on failure', async () => { + resetUserPassword.mockRejectedValueOnce({ + response: { data: { detail: [{ msg: 'Password must be at least 8 characters long' }] } }, + }) + const users = useUsers() + users.handleResetPassword(agent) + + await users.confirmResetPassword('short') + + expect(users.showResetPasswordModal.value).toBe(true) + expect(toastError).toHaveBeenCalledWith( + '出错了', + expect.objectContaining({ + description: '重置密码失败:Password must be at least 8 characters long', + }), + ) + expect(users.resettingPassword.value).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/views/LoginView.spec.ts b/frontend/src/__tests__/views/LoginView.spec.ts new file mode 100644 index 0000000..85197b0 --- /dev/null +++ b/frontend/src/__tests__/views/LoginView.spec.ts @@ -0,0 +1,256 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, VueWrapper, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { createRouter, createWebHistory, Router } from 'vue-router' +import LoginView from '../../views/LoginView.vue' +import { createPinia, setActivePinia } from 'pinia' + +// Mock the services +vi.mock('@/services/auth', () => ({ + authService: { + login: vi.fn() + } +})) + +// The landing route is resolved from the real permission checks reading the +// cached user, so this drives the user rather than mocking the checks away. +const currentUser = vi.hoisted(() => ({ value: null as unknown })) + +vi.mock('@/services/user', () => ({ + userService: { + getCurrentUser: () => currentUser.value, + setCurrentUser: vi.fn(), + getUserId: () => 'user-1', + }, +})) + +// Mock enterprise features +vi.mock('@/composables/useEnterpriseFeatures', () => ({ + useEnterpriseFeatures: vi.fn(() => ({ + hasEnterpriseModule: false, + loadModule: vi.fn().mockResolvedValue(null) + })) +})) + +// Mock forgot password composable +vi.mock('@/composables/useForgotPassword', () => ({ + useForgotPassword: vi.fn(() => ({ + isLoading: { value: false }, + error: { value: '' }, + success: { value: '' }, + currentStep: { value: 1 }, + email: { value: '' }, + otp: { value: '' }, + newPassword: { value: '' }, + confirmPassword: { value: '' }, + passwordValidation: { value: { score: 0, hasMinLength: false, hasUpperCase: false, hasLowerCase: false, hasNumber: false, hasSpecialChar: false } }, + requestPasswordReset: vi.fn(), + verifyAndResetPassword: vi.fn(), + resetForm: vi.fn(), + goBackToEmailStep: vi.fn() + })) +})) + +// Mock Firebase services +vi.mock('@/services/firebase', () => ({ + messaging: {}, + requestNotificationPermission: vi.fn() +})) + +vi.mock('@/composables/useNotifications', () => ({ + useNotifications: vi.fn(() => ({ + requestPermission: vi.fn(), + hasPermission: { value: false } + })) +})) + +// Import the mocked modules +import { authService } from '@/services/auth' +import { userWithPermissions } from '../fixtures/permissions' + +describe('LoginView', () => { + let wrapper: VueWrapper + let router: Router + + beforeEach(async () => { + // Create a fresh router instance for each test + router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
Home
' } }, + { path: '/ai-agents', name: 'ai-agents', component: { template: '
AI Agents
' } }, + { path: '/conversations', name: 'conversations', component: { template: '
Conversations
' } }, + { path: '/human-agents', name: 'human-agents', component: { template: '
Human Agents
' } }, + { path: '/settings/organization', name: 'org-settings', component: { template: '
Organization Settings
' } }, + { path: '/settings/ai-config', name: 'ai-config', component: { template: '
AI Config
' } }, + { path: '/settings/user', name: 'user-settings', component: { template: '
User Settings
' } }, + { path: '/403', name: 'forbidden', component: { template: '
403
' } } + ] + }) + + 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') + }) +}) \ No newline at end of file diff --git a/frontend/src/__tests__/views/SetupView.spec.ts b/frontend/src/__tests__/views/SetupView.spec.ts new file mode 100644 index 0000000..36d4a47 --- /dev/null +++ b/frontend/src/__tests__/views/SetupView.spec.ts @@ -0,0 +1,291 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia, setActivePinia } from 'pinia' +import SetupView from '../../views/SetupView.vue' +import { nextTick } from 'vue' +import { flushPromises } from '@vue/test-utils' + +// Mock timezone-select-js +vi.mock('timezone-select-js', () => ({ + listTz: () => [ + { value: 'America/New_York', label: 'New York' }, + { value: 'Europe/London', label: 'London' }, + { value: 'Asia/Tokyo', label: 'Tokyo' } + ], + clientTz: () => 'America/New_York' +})) + +// Mock the organization service +vi.mock('@/services/organization', () => ({ + createOrganization: vi.fn(), + getSetupStatus: vi.fn().mockResolvedValue(false) +})) + +// Mock Firebase services +vi.mock('@/services/firebase', () => ({ + messaging: {}, + requestNotificationPermission: vi.fn() +})) + +vi.mock('@/composables/useNotifications', () => ({ + useNotifications: vi.fn(() => ({ + requestPermission: vi.fn(), + hasPermission: { value: false } + })) +})) + +// Import mocked services +import { createOrganization, getSetupStatus } from '@/services/organization' + +describe('SetupView', () => { + let wrapper: VueWrapper + let router: any + + beforeEach(async () => { + // Create a fresh pinia instance + setActivePinia(createPinia()) + + // Create router instance + router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
Home
' } }, + { path: '/ai-agents', name: 'ai-agents', component: { template: '
AI Agents
' } } + ] + }) + + // Reset mocks + vi.clearAllMocks() + + // Initialize router + await router.push('/') + await router.isReady() + + wrapper = mount(SetupView, { + global: { + plugins: [router] + } + }) + + // Wait for initial organization check + await nextTick() + }) + + it('renders properly', () => { + expect(wrapper.find('.setup').exists()).toBe(true) + expect(wrapper.find('h1').text()).toBe('欢迎使用 ChatterMate') + }) + + it('checks for existing organization on mount', async () => { + expect(getSetupStatus).toHaveBeenCalled() + }) + + it('redirects to ai-agents if organization exists', async () => { + // Mock organization exists + vi.mocked(getSetupStatus).mockResolvedValueOnce(true) + + // Remount component + wrapper = mount(SetupView, { + global: { + plugins: [router] + } + }) + + // Wait for all promises to resolve and navigation to complete + await flushPromises() + await router.isReady() + + expect(router.currentRoute.value.path).toBe('/login') + expect(getSetupStatus).toHaveBeenCalled() + }) + + it('displays form fields with default values', () => { + expect(wrapper.find('#orgName').exists()).toBe(true) + expect(wrapper.find('#domain').exists()).toBe(true) + expect(wrapper.find('#timezone').exists()).toBe(true) + expect(wrapper.find('#adminName').exists()).toBe(true) + expect(wrapper.find('#adminEmail').exists()).toBe(true) + expect(wrapper.find('#adminPassword').exists()).toBe(true) + expect(wrapper.find('#confirmPassword').exists()).toBe(true) + }) + + it('validates organization name', async () => { + const orgNameInput = wrapper.find('#orgName') + + // Test invalid name + await orgNameInput.setValue('a') + await orgNameInput.trigger('input') + expect(wrapper.find('.error-hint').text()).toContain('组织名称需为') + + // Test valid name + await orgNameInput.setValue('Valid Organization') + await orgNameInput.trigger('input') + expect(wrapper.find('.error-hint').exists()).toBe(false) + }) + + it('validates domain', async () => { + const domainInput = wrapper.find('#domain') + + // Test invalid domain + await domainInput.setValue('invalid') + await domainInput.trigger('input') + expect(wrapper.find('.error-hint').text()).toContain('请输入有效的域名') + + // Test valid domain + await domainInput.setValue('example.com') + await domainInput.trigger('input') + expect(wrapper.find('.error-hint').exists()).toBe(false) + }) + + it('validates admin email', async () => { + const emailInput = wrapper.find('#adminEmail') + + // Test invalid email + await emailInput.setValue('invalid-email') + await emailInput.trigger('input') + expect(wrapper.find('.error-hint').text()).toContain('请输入有效的邮箱地址') + + // Test valid email + await emailInput.setValue('admin@example.com') + await emailInput.trigger('input') + expect(wrapper.find('.error-hint').exists()).toBe(false) + }) + + it('validates password strength', async () => { + const passwordInput = wrapper.find('#adminPassword') + + // Test weak password + await passwordInput.setValue('weak') + await passwordInput.trigger('input') + expect(wrapper.find('.strength-bar').classes()).toContain('weak') + + // Test strong password + await passwordInput.setValue('StrongP@ss123') + await passwordInput.trigger('input') + expect(wrapper.find('.strength-bar').classes()).toContain('strong') + }) + + it('validates password confirmation', async () => { + // Fill in all required fields with valid data first + await wrapper.find('#orgName').setValue('Test Organization') + await wrapper.find('#domain').setValue('test.com') + await wrapper.find('#adminName').setValue('Test Admin') + await wrapper.find('#adminEmail').setValue('admin@test.com') + + // Set different passwords + const passwordInput = wrapper.find('#adminPassword') + const confirmInput = wrapper.find('#confirmPassword') + + await passwordInput.setValue('StrongP@ss123') + await passwordInput.trigger('input') + await confirmInput.setValue('DifferentP@ss123') + + // Submit form + await wrapper.find('form').trigger('submit') + await nextTick() + + expect(wrapper.find('.error-message').text()).toBe('两次输入的密码不一致') + }) + + it('handles timezone selection', async () => { + const timezoneSelect = wrapper.find('#timezone') + await timezoneSelect.setValue('Europe/London') + + expect((wrapper.vm as any).selectedTimezone).toBe('Europe/London') + expect((wrapper.vm as any).orgData.timezone).toBe('Europe/London') + }) + + it('handles business hours configuration', async () => { + // Toggle Saturday to enabled + const saturdayToggle = wrapper.findAll('.toggle input')[5] + await saturdayToggle.setValue(true) + + // Change Saturday hours + const timeSelects = wrapper.findAll('.time-selects select') + await timeSelects[10].setValue('10:00') // Start time + await timeSelects[11].setValue('18:00') // End time + + const businessHours = (wrapper.vm as any).orgData.business_hours + expect(businessHours.saturday.enabled).toBe(true) + expect(businessHours.saturday.start).toBe('10:00') + expect(businessHours.saturday.end).toBe('18:00') + }) + + it('submits form with valid data', async () => { + // Fill in all required fields with valid data + await wrapper.find('#orgName').setValue('Test Organization') + await wrapper.find('#domain').setValue('test.com') + await wrapper.find('#adminName').setValue('Test Admin') + await wrapper.find('#adminEmail').setValue('admin@test.com') + await wrapper.find('#adminPassword').setValue('StrongP@ss123') + await wrapper.find('#confirmPassword').setValue('StrongP@ss123') + + // Submit form + await wrapper.find('form').trigger('submit') + await nextTick() + + // Verify organization creation was called + expect(createOrganization).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Test Organization', + domain: 'test.com', + admin_name: 'Test Admin', + admin_email: 'admin@test.com', + admin_password: 'StrongP@ss123' + })) + }) + + it('handles organization creation error', async () => { + const error = 'Failed to create organization' + ;(createOrganization as any).mockRejectedValueOnce(new Error(error)) + + // Fill in required fields + await wrapper.find('#orgName').setValue('Test Organization') + await wrapper.find('#domain').setValue('test.com') + await wrapper.find('#adminName').setValue('Test Admin') + await wrapper.find('#adminEmail').setValue('admin@test.com') + await wrapper.find('#adminPassword').setValue('StrongP@ss123') + await wrapper.find('#confirmPassword').setValue('StrongP@ss123') + + // Submit form + await wrapper.find('form').trigger('submit') + await nextTick() + + expect(wrapper.find('.error-message').text()).toBe(error) + }) + + it('shows loading state during submission', async () => { + // Mock slow organization creation + ;(createOrganization as any).mockImplementationOnce(() => new Promise(resolve => setTimeout(resolve, 100))) + + // Fill in required fields + await wrapper.find('#orgName').setValue('Test Organization') + await wrapper.find('#domain').setValue('test.com') + await wrapper.find('#adminName').setValue('Test Admin') + await wrapper.find('#adminEmail').setValue('admin@test.com') + await wrapper.find('#adminPassword').setValue('StrongP@ss123') + await wrapper.find('#confirmPassword').setValue('StrongP@ss123') + + // Submit form + await wrapper.find('form').trigger('submit') + await nextTick() + + expect(wrapper.find('button[type="submit"]').classes()).toContain('loading') + }) +}) \ No newline at end of file diff --git a/frontend/src/components/common/BaseModal.vue b/frontend/src/components/common/BaseModal.vue new file mode 100644 index 0000000..dc0a35d --- /dev/null +++ b/frontend/src/components/common/BaseModal.vue @@ -0,0 +1,192 @@ + + + + + + + diff --git a/frontend/src/components/common/ChannelBadge.vue b/frontend/src/components/common/ChannelBadge.vue new file mode 100644 index 0000000..d1f6434 --- /dev/null +++ b/frontend/src/components/common/ChannelBadge.vue @@ -0,0 +1,58 @@ + + + + + + + diff --git a/frontend/src/components/common/FileUpload.vue b/frontend/src/components/common/FileUpload.vue new file mode 100644 index 0000000..fe73616 --- /dev/null +++ b/frontend/src/components/common/FileUpload.vue @@ -0,0 +1,795 @@ + + + + + + + diff --git a/frontend/src/components/common/GuidanceTextarea.vue b/frontend/src/components/common/GuidanceTextarea.vue new file mode 100644 index 0000000..0cd3f25 --- /dev/null +++ b/frontend/src/components/common/GuidanceTextarea.vue @@ -0,0 +1,181 @@ + + + + + + + diff --git a/frontend/src/components/common/Modal.vue b/frontend/src/components/common/Modal.vue new file mode 100644 index 0000000..7972e9b --- /dev/null +++ b/frontend/src/components/common/Modal.vue @@ -0,0 +1,119 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/common/PasswordStrengthMeter.vue b/frontend/src/components/common/PasswordStrengthMeter.vue new file mode 100644 index 0000000..1ff925c --- /dev/null +++ b/frontend/src/components/common/PasswordStrengthMeter.vue @@ -0,0 +1,114 @@ + + + + + + + diff --git a/frontend/src/components/human-agent/GroupForm.vue b/frontend/src/components/human-agent/GroupForm.vue new file mode 100644 index 0000000..c7161b5 --- /dev/null +++ b/frontend/src/components/human-agent/GroupForm.vue @@ -0,0 +1,99 @@ + + + + +