Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
e9d1dba
feat: DingTalk media support, secrets management, security hardening …
Mar 28, 2026
a8976c0
Merge remote-tracking branch 'upstream/main' into feature/dingtalk-me…
Mar 28, 2026
f1e82b7
Merge remote-tracking branch 'upstream/main' into feature/dingtalk-me…
Mar 28, 2026
01355e6
Merge remote-tracking branch 'upstream/main' into feature/dingtalk-me…
Mar 28, 2026
294257d
refactor: centralize DingTalk token management with global cache
Mar 28, 2026
27c0aa3
fix(dingtalk): remove unsupported context_size param from _call_agent…
Mar 28, 2026
860cb25
fix: context_window_size 动态生效 - 去掉 _call_agent_llm 内层 history[-10:] 硬…
Mar 30, 2026
81e0a49
merge: upstream/main - SSO+组织同步、工具管理、密码重置、邮件本地化
Mar 30, 2026
10a88ad
fix: upstream残留问题 - 删除不存在的password_reset_token模型import - auth.py补充缺失的…
Mar 30, 2026
5f0f448
fix: add_user_source migration幂等化 - 检查列和索引是否已存在再添加
Mar 30, 2026
ac87f10
feat: implement Generic OAuth2 SSO login flow
Mar 30, 2026
3883f1a
fix: sso callback URL 从 request 动态获取 scheme+host+port
Mar 30, 2026
c5905ee
fix: OAuth2 token 请求改用 form-urlencoded (RFC 6749)
Mar 30, 2026
d758479
feat: OAuth2配置表单增加Token URL/UserInfo URL/Scope字段
Mar 30, 2026
124a4ed
feat: 域名统一管理 - 三级降级链(租户→全局→请求)
Mar 30, 2026
1bb7f44
feat: 域名统一管理 - 三级降级链(租户→全局→请求)
Mar 30, 2026
5a4b574
fix: TenantOut增加effective_base_url字段 + resolve-by-domain支持hostname不带端口匹配
Mar 30, 2026
da722cf
fix: SSO登录按钮优先显示provider的Display Name
Mar 30, 2026
2bc23f9
feat: add subdomain_prefix for company-specific access URLs
Mar 30, 2026
b4068f5
feat: multi-dimension user matching for DingTalk bot messages
Mar 30, 2026
e94536b
fix: registration_service Tenant.custom_domain/domain → sso_domain
Mar 30, 2026
2cb741a
Merge branch 'main' into feature/dingtalk-media-support
Mar 30, 2026
d6aa285
fix: add subdomain_prefix to admin companies API, remove sso_domain i…
Mar 30, 2026
58793b5
feat: remove manual SSO toggle from company edit modal and org settings
Mar 30, 2026
9363929
feat: tenant domain auto-gen + slug editable
Mar 30, 2026
f8a6627
fix(EditCompanyModal): i18n status badges, slug-based placeholder and…
Mar 30, 2026
21c03a9
fix: restore multi-dimension DingTalk user matching (lost in media su…
Mar 30, 2026
bee13fb
fix(dingtalk): fix gettoken POST->GET and redesign user matching stra…
Mar 30, 2026
12162df
feat: add default tenant + remove slug editing
Mar 30, 2026
a15c20b
fix: 统一钉钉access_token获取,复用DingTalkTokenManager
Mar 30, 2026
930186b
fix: 钉钉用户匹配优先级调整,改用 senderStaffId 作为主键
Mar 30, 2026
a63b7ce
fix: AdminCompanies className 引号修复
Mar 30, 2026
94ef208
fix: AdminCompanies 所有 className 引号修复
Mar 30, 2026
88409bd
fix: AdminCompanies SVG属性引号修复 + fetchJson URL参数恢复
Mar 30, 2026
e85f116
fix: add is_default to CompanyStats and guard against disabling defau…
Mar 30, 2026
5afbfd6
fix: 停用按钮用is_default判断而非硬编码slug
Mar 30, 2026
17fbdf8
fix: seed_default_agents 复用外部db session,确保新企业agent正确关联
Mar 30, 2026
8cf529c
feat: 企业删除(级联) + 用户信息编辑(profile API + EditUserModal)
Mar 30, 2026
ef07bf8
fix: batch admin UI fixes - i18n, password reset, org member display
Mar 30, 2026
f1d82c4
feat: seed default agents on company create + invitation code management
Mar 31, 2026
3eaeed1
refactor: replace hardcoded try.clawith.ai with dynamic resolve_base_url
Mar 31, 2026
0c73f7b
fix: allow unbound users (tenant_id=None) to pass tenant-scoped login…
Mar 31, 2026
f847fd0
chore: remove stale process docs
Mar 31, 2026
016e9e3
merge: upstream/main into feature/tenant-domain-management
Mar 31, 2026
2b3d43f
fix: list_triggers returns full webhook URL via resolve_base_url
Mar 31, 2026
8b30577
fix: inject platform base_url into agent system prompt dynamic context
Mar 31, 2026
691d099
fix: expand platform URL block in system prompt with all URL patterns
Mar 31, 2026
de84fef
fix: auto-title session on first message when title is New Session
Mar 31, 2026
f410e0e
fix: dingtalk stream immediate ACK + message_id dedup to prevent dupl…
Mar 31, 2026
e62cfeb
refactor: migrate in-memory token caches to Redis with memory fallback
Mar 31, 2026
13994a1
fix: audit fixes - missing imports in agent_context, cascade delete g…
Mar 31, 2026
cd8aa56
Merge remote-tracking branch 'upstream/main' into feature/tenant-doma…
Mar 31, 2026
b05da37
feat: OAuth2 field mapping config + enhanced user matching chain
Mar 31, 2026
dec5f97
release: v1.8.1 - OAuth2 field mapping + enhanced user matching chain
Mar 31, 2026
4fa57c8
fix: handle OAuth2 userinfo 401/empty response gracefully
Mar 31, 2026
2ebd210
fix: import HTTPException in OAuth2AuthProvider
Mar 31, 2026
b7e31bb
fix: OAuth2 userinfo 401 fallback to token_data for 爷爷茶 new users
Mar 31, 2026
1c259f9
fix: add debug logging for OAuth2 token_data fallback
Mar 31, 2026
e2ae42b
fix: use openid as fallback provider_user_id for 爷爷茶 OAuth2
Mar 31, 2026
5e4be62
fix: restore original userinfo logic (remove 401 check)
Mar 31, 2026
0c6eb29
fix: use openid[:8] as username for OAuth2 users without employee pro…
Mar 31, 2026
1745e7a
fix: OAuth2 用户匹配支持 tenant_id=NULL + 自动绑定租户
Mar 31, 2026
955a4db
fix: OAuth2 用户匹配支持 tenant_id=NULL + 自动绑定租户
Mar 31, 2026
405846e
fix: remove debug
Mar 31, 2026
9b75fc9
fix: 移除 OAuth2 用户名截断逻辑
Mar 31, 2026
b3b0b1b
fix: OAuth2 匹配 tenant_id=NULL 的现有用户
Mar 31, 2026
7acf08a
ui: 隐藏登录页面 SSO 提示框
Mar 31, 2026
c0024c3
feat: OAuth2 表单重构 - 严格 config 格式 + 字段映射清空功能
Mar 31, 2026
2aa30d2
fix: OAuth2 tenant binding 表单优化和国际化更新
Apr 1, 2026
56b0b8c
Merge feature/dingtalk-media-support into integrate-local-features
Apr 1, 2026
dc8d252
fix: restore auth_provider features lost in merge 56b0b8c
Apr 1, 2026
7618b38
feat: cross-channel user auto-merge via mobile/email/display_name
Apr 1, 2026
106abbc
fix: remove display_name matching - only match by mobile/email
Apr 1, 2026
2c6d16d
fix: OAuth2 login updates dingtalk_ temporary username to provider_us…
Apr 1, 2026
e161599
merge: upstream/main into feature/merge-upstream-20260401
Apr 1, 2026
fc751a1
fix: remove undefined ssoEnabled/handleToggle refs in SsoStatus compo…
Apr 1, 2026
54bf9bc
fix: restore resolve_base_url for SSO callbacks + add DB fallback to …
Apr 1, 2026
7105e20
fix: default tenant skips subdomain prefix in URL resolution + no aut…
Apr 1, 2026
cd0b9ce
feat: adapt auth_provider + dingtalk + sso to Identity architecture
Apr 1, 2026
52673be
fix: resolve-by-domain port handling + remaining Identity proxy adapt…
Apr 1, 2026
b647a6e
fix: resolve async MissingGreenlet errors caused by Identity associat…
Apr 2, 2026
d9cba87
chore: add merge migration for identity refactor + tenant_is_default …
Apr 2, 2026
84a9442
merge: sync upstream/main (v1.8.0-beta.2, SSO fixes, Take Control imp…
Apr 2, 2026
51fff11
fix: correct username reference before assignment in _create_new_user…
Apr 2, 2026
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
27 changes: 27 additions & 0 deletions backend/alembic/versions/add_subdomain_prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Add subdomain_prefix to tenants table."""

from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
from typing import Sequence, Union

revision: str = "add_subdomain_prefix"
down_revision = ("add_tool_source", "merge_upstream_and_local")
branch_labels = None
depends_on = None


def upgrade() -> None:
conn = op.get_bind()
inspector = inspect(conn)
columns = [c["name"] for c in inspector.get_columns("tenants")]
if "subdomain_prefix" not in columns:
op.add_column("tenants", sa.Column("subdomain_prefix", sa.String(50), nullable=True))
indexes = [i["name"] for i in inspector.get_indexes("tenants")]
if "ix_tenants_subdomain_prefix" not in indexes:
op.create_index("ix_tenants_subdomain_prefix", "tenants", ["subdomain_prefix"], unique=True)


def downgrade() -> None:
op.drop_index("ix_tenants_subdomain_prefix", "tenants")
op.drop_column("tenants", "subdomain_prefix")
38 changes: 38 additions & 0 deletions backend/alembic/versions/add_tenant_is_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Add is_default field to tenants table.

Revision ID: add_tenant_is_default
Revises: add_subdomain_prefix
Create Date: 2026-03-30
"""

from alembic import op
import sqlalchemy as sa

revision = "add_tenant_is_default"
down_revision = "add_subdomain_prefix"
branch_labels = None
depends_on = None


def upgrade() -> None:
conn = op.get_bind()

# 1. Add is_default column (idempotent)
inspector = sa.inspect(conn)
cols = [c['name'] for c in inspector.get_columns('tenants')]
if 'is_default' not in cols:
op.add_column('tenants', sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'))

# 2. Set the earliest active tenant as default (only if no tenant is already default)
conn.execute(sa.text("""
UPDATE tenants
SET is_default = true
WHERE id = (
SELECT id FROM tenants WHERE is_active = true ORDER BY created_at ASC LIMIT 1
)
AND NOT EXISTS (SELECT 1 FROM tenants WHERE is_default = true)
"""))


def downgrade() -> None:
op.drop_column('tenants', 'is_default')
26 changes: 26 additions & 0 deletions backend/alembic/versions/add_user_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Add source column to users table."""

from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect

revision = "add_user_source"
down_revision = "add_llm_max_output_tokens"
branch_labels = None
depends_on = None


def upgrade():
conn = op.get_bind()
inspector = inspect(conn)
columns = [c["name"] for c in inspector.get_columns("users")]
if "source" not in columns:
op.add_column("users", sa.Column("source", sa.String(50), nullable=True, server_default="web"))
indexes = [i["name"] for i in inspector.get_indexes("users")]
if "ix_users_source" not in indexes:
op.create_index("ix_users_source", "users", ["source"])


def downgrade():
op.drop_index("ix_users_source", "users")
op.drop_column("users", "source")
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""merge identity refactor with tenant_is_default

Revision ID: f8a934bf9f17
Revises: add_tenant_is_default, d9cbd43b62e5
Create Date: 2026-04-02
"""
from typing import Sequence, Union

# revision identifiers, used by Alembic.
revision: str = 'f8a934bf9f17'
down_revision: Union[str, Sequence[str]] = ('add_tenant_is_default', 'd9cbd43b62e5')
branch_labels = None
depends_on = None


def upgrade() -> None:
pass


def downgrade() -> None:
pass
16 changes: 16 additions & 0 deletions backend/alembic/versions/merge_heads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Merge upstream and local migration heads."""

from alembic import op

revision = "merge_upstream_and_local"
down_revision = ("add_daily_token_usage", "add_user_source")
branch_labels = None
depends_on = None


def upgrade():
pass


def downgrade():
pass
7 changes: 5 additions & 2 deletions backend/app/api/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ async def get_agent_activity(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get recent activity logs for an agent."""
await check_agent_access(db, current_user, agent_id)
"""Get recent activity logs for an agent. Only the agent creator can view."""
from app.core.permissions import is_agent_creator
agent, _access = await check_agent_access(db, current_user, agent_id)
if not is_agent_creator(current_user, agent):
return []

result = await db.execute(
select(AgentActivityLog)
Expand Down
196 changes: 196 additions & 0 deletions backend/app/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from datetime import datetime

from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy import func as sqla_func, select
from sqlalchemy.ext.asyncio import AsyncSession
Expand All @@ -31,8 +32,10 @@ class CompanyStats(BaseModel):
name: str
slug: str
is_active: bool
is_default: bool = False
sso_enabled: bool = False
sso_domain: str | None = None
subdomain_prefix: str | None = None
created_at: datetime | None = None
user_count: int = 0
agent_count: int = 0
Expand Down Expand Up @@ -117,8 +120,10 @@ async def list_companies(
name=tenant.name,
slug=tenant.slug,
is_active=tenant.is_active,
is_default=tenant.is_default,
sso_enabled=tenant.sso_enabled,
sso_domain=tenant.sso_domain,
subdomain_prefix=tenant.subdomain_prefix,
created_at=tenant.created_at,
user_count=user_count,
agent_count=agent_count,
Expand Down Expand Up @@ -159,6 +164,13 @@ async def create_company(
db.add(invite)
await db.flush()

# Seed default agents (Morty & Meeseeks) for the new company
try:
from app.services.agent_seeder import seed_default_agents
await seed_default_agents(tenant_id=tenant.id, creator_id=current_user.id, db=db)
except Exception as e:
logger.warning(f"[create_company] Failed to seed default agents: {e}")

return CompanyCreateResponse(
company=CompanyStats(
id=tenant.id,
Expand Down Expand Up @@ -198,6 +210,56 @@ async def toggle_company(
return {"ok": True, "is_active": new_state}



@router.get("/companies/{company_id}/invitation-codes")
async def list_company_invitation_codes(
company_id: uuid.UUID,
current_user: User = Depends(require_role("platform_admin")),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(InvitationCode)
.where(InvitationCode.tenant_id == company_id)
.where(InvitationCode.is_active == True)
.order_by(InvitationCode.created_at.desc())
)
codes = result.scalars().all()
return {
"codes": [
{
"id": str(c.id),
"code": c.code,
"max_uses": c.max_uses,
"used_count": c.used_count,
"is_active": c.is_active,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in codes
]
}


@router.post("/companies/{company_id}/invitation-codes")
async def create_company_invitation_code(
company_id: uuid.UUID,
current_user: User = Depends(require_role("platform_admin")),
db: AsyncSession = Depends(get_db),
):
t_result = await db.execute(select(Tenant).where(Tenant.id == company_id))
if not t_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Company not found")

code_str = secrets.token_urlsafe(12)[:16].upper()
invite = InvitationCode(
code=code_str,
tenant_id=company_id,
max_uses=1,
created_by=current_user.id,
)
db.add(invite)
await db.flush()
return {"code": code_str}

# ─── Platform Metrics Dashboard ─────────────────────────

from typing import Any
Expand Down Expand Up @@ -556,3 +618,137 @@ async def update_platform_settings(

await db.flush()
return await get_platform_settings(current_user=current_user, db=db)


@router.delete("/companies/{company_id}", status_code=204)
async def delete_company(
company_id: uuid.UUID,
current_user: User = Depends(require_role("platform_admin")),
db: AsyncSession = Depends(get_db),
):
"""Permanently delete a company and all associated data.

Cannot delete the default company.
Cascade-deletes all related records in dependency order.
"""
# 1. Find the tenant
result = await db.execute(select(Tenant).where(Tenant.id == company_id))
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(status_code=404, detail="Company not found")

# 2. Cannot delete default company
if tenant.is_default:
raise HTTPException(
status_code=400,
detail="Cannot delete the default company. Please set another company as default first."
)

# 3. Cascade delete all associated data
from sqlalchemy import delete as sa_delete
from app.models.activity_log import AgentActivityLog, DailyTokenUsage
from app.models.audit import AuditLog, ApprovalRequest, ChatMessage
from app.models.channel_config import ChannelConfig
from app.models.chat_session import ChatSession
from app.models.gateway_message import GatewayMessage
from app.models.llm import LLMModel
from app.models.notification import Notification
from app.models.org import OrgMember, OrgDepartment, AgentRelationship, AgentAgentRelationship
from app.models.published_page import PublishedPage
from app.models.schedule import AgentSchedule
from app.models.skill import Skill
from app.models.task import Task
from app.models.tenant_setting import TenantSetting
from app.models.trigger import AgentTrigger
from app.models.tool import AgentTool
from app.models.identity import IdentityProvider, SSOScanSession

# 3.1 Collect agent_ids and user_ids for this tenant
agent_ids_result = await db.execute(
select(Agent.id).where(Agent.tenant_id == company_id)
)
agent_ids = [row[0] for row in agent_ids_result.all()]

user_ids_result = await db.execute(
select(User.id).where(User.tenant_id == company_id)
)
user_ids = [row[0] for row in user_ids_result.all()]

# 3.2 Delete tables that reference agents (via agent_id)
if agent_ids:
await db.execute(sa_delete(AgentTrigger).where(AgentTrigger.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentSchedule).where(AgentSchedule.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentActivityLog).where(AgentActivityLog.agent_id.in_(agent_ids)))
await db.execute(sa_delete(ChannelConfig).where(ChannelConfig.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentTool).where(AgentTool.agent_id.in_(agent_ids)))
await db.execute(sa_delete(Notification).where(Notification.agent_id.in_(agent_ids)))
# Delete TaskLog before Task (FK: task_id -> tasks.id, no cascade)
from app.models.task import TaskLog
task_ids_r = await db.execute(select(Task.id).where(Task.agent_id.in_(agent_ids)))
task_ids = [row[0] for row in task_ids_r.all()]
if task_ids:
await db.execute(sa_delete(TaskLog).where(TaskLog.task_id.in_(task_ids)))
await db.execute(sa_delete(Task).where(Task.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AuditLog).where(AuditLog.agent_id.in_(agent_ids)))
await db.execute(sa_delete(ApprovalRequest).where(ApprovalRequest.agent_id.in_(agent_ids)))
await db.execute(sa_delete(ChatMessage).where(ChatMessage.agent_id.in_(agent_ids)))
await db.execute(sa_delete(ChatSession).where(ChatSession.agent_id.in_(agent_ids)))
await db.execute(sa_delete(GatewayMessage).where(GatewayMessage.agent_id.in_(agent_ids)))
from app.models.agent import AgentPermission
await db.execute(sa_delete(AgentPermission).where(AgentPermission.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentAgentRelationship).where(AgentAgentRelationship.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentAgentRelationship).where(AgentAgentRelationship.target_agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentRelationship).where(AgentRelationship.agent_id.in_(agent_ids)))

# Null out cross-tenant FK references (other tenants' records pointing to our agents)
from sqlalchemy import update as sa_update
await db.execute(
sa_update(ChatSession).where(ChatSession.peer_agent_id.in_(agent_ids)).values(peer_agent_id=None)
)
await db.execute(
sa_update(GatewayMessage).where(GatewayMessage.sender_agent_id.in_(agent_ids)).values(sender_agent_id=None)
)

# 3.3 Delete tables that reference users but not tenant directly
if user_ids:
from app.models.agent import AgentTemplate
await db.execute(sa_delete(AgentTemplate).where(AgentTemplate.created_by.in_(user_ids)))

# 3.3b Null out cross-tenant user FK references
if user_ids:
from sqlalchemy import update as sa_update
await db.execute(
sa_update(GatewayMessage).where(GatewayMessage.sender_user_id.in_(user_ids)).values(sender_user_id=None)
)

# 3.4 Delete tables with tenant_id (no agent dependency)
await db.execute(sa_delete(DailyTokenUsage).where(DailyTokenUsage.tenant_id == company_id))
await db.execute(sa_delete(PublishedPage).where(PublishedPage.tenant_id == company_id))
await db.execute(sa_delete(OrgMember).where(OrgMember.tenant_id == company_id))
await db.execute(sa_delete(OrgDepartment).where(OrgDepartment.tenant_id == company_id))
await db.execute(sa_delete(InvitationCode).where(InvitationCode.tenant_id == company_id))
# Delete SkillFile before Skill (FK: skill_id -> skills.id, no cascade)
from app.models.skill import SkillFile
skill_ids_r = await db.execute(select(Skill.id).where(Skill.tenant_id == company_id))
skill_ids = [row[0] for row in skill_ids_r.all()]
if skill_ids:
await db.execute(sa_delete(SkillFile).where(SkillFile.skill_id.in_(skill_ids)))
await db.execute(sa_delete(Skill).where(Skill.tenant_id == company_id))
await db.execute(sa_delete(LLMModel).where(LLMModel.tenant_id == company_id))
await db.execute(sa_delete(TenantSetting).where(TenantSetting.tenant_id == company_id))

# 3.4b Delete identity tables with tenant_id (soft FK)
await db.execute(sa_delete(IdentityProvider).where(IdentityProvider.tenant_id == company_id))
await db.execute(sa_delete(SSOScanSession).where(SSOScanSession.tenant_id == company_id))

# 3.5 Delete agents (after all agent-dependent tables)
await db.execute(sa_delete(Agent).where(Agent.tenant_id == company_id))

# 3.6 Delete users (after agents, since agents.creator_id -> users.id)
await db.execute(sa_delete(User).where(User.tenant_id == company_id))

# 3.7 Delete the tenant itself
await db.delete(tenant)
await db.flush()

return None
Loading