Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile.frontend
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ cd /app && npm run build:widgetdocker\n\
cd /app && npm run build:webclient\n\
\n\
echo "Starting frontend development server"\n\
cd /app && npm run predev && npm run dev -- --host 0.0.0.0 --port 3000' > /usr/local/bin/start.sh
cd /app && npm run dev -- --host 0.0.0.0 --port 3000' > /usr/local/bin/start.sh

# Make startup script executable
RUN chmod +x /usr/local/bin/start.sh
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""
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.
"""

"""add view_unassigned_chats + view_people permissions

Revision ID: b7c4e91a2d38
Revises: add_customer_phone_001
Create Date: 2026-07-18

Agents could previously only see chats already assigned to them or their group,
so the AI queue was invisible and "Take over" was unreachable. Two new
permissions rather than widening view_assigned_chats, so that granting the
queue never also exposes another agent's conversations:

- view_unassigned_chats: the unclaimed queue (session_to_agents.user_id IS NULL)
- view_people: read-only people directory, previously gated on view_all_chats

Backfill targets roles by CAPABILITY, not by name, so every existing
organization is covered including ones whose agent role was renamed or
hand-built:

- any role that can view chats at all (assigned, all, or super_admin) gains
view_unassigned_chats
- the same roles plus manage_all_chats gain view_people

This deliberately widens existing roles: without it the feature would appear
broken for every deployment created before this release, and an admin who
wants an agent kept out can uncheck the permission in Roles. Re-runnable —
every insert is guarded by NOT EXISTS.

Also repairs the seeding bug where BOTH "Admin" and "Agent" were created with
is_default=true, which made RoleRepository.get_default_role() return whichever
row the database happened to yield first — an invited user could land on Admin.
Nothing in the API enforces a single default, so the repair is data-only.

The repair CLEARS Admin, which is what leaves Agent as the default. Setting
is_default=false on Agent instead would do the opposite of what is wanted: it
would leave Admin as the sole default and hand every newly invited user full
permissions.
"""

from alembic import op

# revision identifiers, used by Alembic.
revision = 'b7c4e91a2d38'
down_revision = 'add_customer_phone_001'
branch_labels = None
depends_on = None


NEW_PERMISSIONS = (
('view_unassigned_chats', 'Can view unassigned AI chats'),
('view_people', 'Can view the people directory'),
)

# Which existing roles receive each new permission, expressed as the permissions
# a role must already hold. Capability, not role name: an org that renamed its
# agent role, or built its own, is still covered.
BACKFILL = (
('view_unassigned_chats', ('view_assigned_chats', 'view_all_chats', 'super_admin')),
('view_people', ('view_assigned_chats', 'view_all_chats', 'manage_all_chats', 'super_admin')),
)


def _sql_list(values) -> str:
return ", ".join(f"'{value}'" for value in values)


def upgrade() -> None:
for name, description in NEW_PERMISSIONS:
op.execute(
f"""
INSERT INTO permissions (name, description)
SELECT '{name}', '{description}'
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = '{name}')
"""
)

for new_permission, held_by in BACKFILL:
op.execute(
f"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT DISTINCT r.id, target.id
FROM roles r
JOIN role_permissions rp ON rp.role_id = r.id
JOIN permissions held ON held.id = rp.permission_id
CROSS JOIN permissions target
WHERE target.name = '{new_permission}'
AND held.name IN ({_sql_list(held_by)})
AND NOT EXISTS (
SELECT 1 FROM role_permissions existing
WHERE existing.role_id = r.id
AND existing.permission_id = target.id
)
"""
)

# One default role per organization. Clearing Admin is what PROMOTES Agent
# to sole default — the opposite spelling (clearing Agent) would leave
# Admin default and make every invited user an admin.
#
# The COUNT(*) > 1 guard is load-bearing: it means an org whose only
# default is Admin keeps it, rather than being left with no default role
# at all. An org that renamed its Admin role keeps both defaults — the
# pre-existing ambiguity, not a regression.
op.execute(
"""
UPDATE roles SET is_default = false
WHERE name = 'Admin'
AND is_default = true
AND organization_id IN (
SELECT organization_id FROM roles
WHERE is_default = true
GROUP BY organization_id
HAVING COUNT(*) > 1
)
"""
)


def downgrade() -> None:
# Drop the grants first (FK), then the permissions themselves. is_default is
# not restored: the pre-migration state was ambiguous by definition.
op.execute(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE name IN ('view_unassigned_chats', 'view_people')
)
"""
)
op.execute(
"""
DELETE FROM permissions
WHERE name IN ('view_unassigned_chats', 'view_people')
"""
)
36 changes: 21 additions & 15 deletions backend/app/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,31 +78,34 @@ async def get_recent_chats(

# For JWT auth, use permissions from auth_info
current_user = auth_info['current_user']
can_view_all = auth_info['can_view_all']
can_view_assigned = auth_info['can_view_assigned']

can_view_all = auth_info.get('can_view_all', False)
can_view_assigned = auth_info.get('can_view_assigned', False)
can_view_unassigned = auth_info.get('can_view_unassigned', False)

# Get user's group IDs
user_group_ids = [str(group.id) for group in current_user.groups]
logger.debug(f"User groups: {user_group_ids}")
logger.debug(f"current_user.user_id: {current_user.id}")

# If user can only view assigned chats, filter by user_id and groups
if not can_view_all and can_view_assigned:

# Scoped inbox: own sessions + group queue (view_assigned_chats) and/or
# the unclaimed AI queue (view_unassigned_chats)
if not can_view_all:
return chat_repo.get_recent_chats(
skip=skip,
limit=limit,
agent_id=agent_id,
status=status,
user_id=current_user.id, # Pass UUID directly
user_groups=user_group_ids,
user_id=current_user.id if can_view_assigned else None,
user_groups=user_group_ids if can_view_assigned else None,
include_unassigned=can_view_unassigned,
organization_id=organization_id,
user_name=user_name,
filter_user_id=user_id, # New parameter for filtering by specific user
customer_email=customer_email,
date_from=date_from,
date_to=date_to
)

# For users with view_all_chats permission
return chat_repo.get_recent_chats(
skip=skip,
Expand Down Expand Up @@ -157,18 +160,21 @@ async def get_chat_detail(
else:
# For JWT auth, use permissions from auth_info
current_user = auth_info['current_user']
can_view_all = auth_info['can_view_all']
can_view_assigned = auth_info['can_view_assigned']
can_view_all = auth_info.get('can_view_all', False)
can_view_assigned = auth_info.get('can_view_assigned', False)
can_view_unassigned = auth_info.get('can_view_unassigned', False)

# Get user's group IDs
user_group_ids = [str(group.id) for group in current_user.groups]

# If user can only view assigned chats, verify access
if not can_view_all and can_view_assigned:
# Scoped readers must own the session, share its group, or — with
# view_unassigned_chats — find it unclaimed
if not can_view_all:
has_access = await chat_repo.check_session_access(
session_id=session_id_uuid,
user_id=current_user.id,
user_groups=user_group_ids
user_id=current_user.id if can_view_assigned else None,
user_groups=user_group_ids if can_view_assigned else [],
include_unassigned=can_view_unassigned
)
if not has_access:
raise HTTPException(
Expand Down
16 changes: 12 additions & 4 deletions backend/app/api/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,25 +93,33 @@ async def create_organization(
db.flush()
permissions[name] = perm

# Create default roles
# 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=True
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",
description="Access to assigned chats and the unclaimed AI queue",
organization_id=organization.id,
is_default=True
)
agent_role.permissions = [
permissions["view_assigned_chats"],
permissions["manage_assigned_chats"]
permissions["manage_assigned_chats"],
# Lets an agent see and claim chats the AI is still handling
permissions["view_unassigned_chats"],
# Read-only directory access; mutating a person still needs more
permissions["view_people"]
]
db.add(agent_role)
db.flush()
Expand Down
32 changes: 21 additions & 11 deletions backend/app/api/people.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@

from app.core.logger import get_logger
from app.database import get_db
from app.core.auth import INBOX_PERMISSIONS, get_current_user, has_any_permission
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.models.schemas.people import (
Expand All @@ -40,14 +45,19 @@
router = APIRouter()
logger = get_logger(__name__)

def _require_people_access(current_user: User, db: Session) -> None:
# People is an org-wide view of every lead/customer, so it needs a broad
# chat capability — not the limited "assigned chats only" permission.
# INBOX_PERMISSIONS is shared with the WhatsApp template/outbound endpoints
# so the two surfaces agree on who works the inbox, and 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, INBOX_PERMISSIONS):
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:
Expand Down Expand Up @@ -116,7 +126,7 @@ async def mark_as_customer(
db: Session = Depends(get_db),
):
"""Manually promote a person to the Customer stage (phase 1: no automated signal)."""
_require_people_access(current_user, db)
_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:
Expand All @@ -142,7 +152,7 @@ async def update_person(

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)
_require_people_access(current_user, db, PEOPLE_WRITE_PERMISSIONS)
repo = PeopleRepository(db)
customer, error = repo.update_person(
current_user.organization_id, customer_id,
Expand Down
Loading
Loading