diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index e34c45782..94781e04f 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -500,6 +500,7 @@ async def login(data: UserLogin, background_tasks: BackgroundTasks, db: AsyncSes tenant_id=u.tenant_id, tenant_name=tenant.name if tenant else "Create or Join Organization", tenant_slug=tenant.slug if tenant else "", + logo_url=tenant.logo_url if tenant else None, )) return MultiTenantResponse( @@ -759,7 +760,8 @@ async def get_my_tenants( TenantChoice( tenant_id=t.id, tenant_name=t.name, - tenant_slug=t.slug + tenant_slug=t.slug, + logo_url=t.logo_url, ) for t in tenants ] diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index f5b526343..ae4a97ced 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -7,13 +7,19 @@ import re import secrets import uuid +import io from datetime import datetime +from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, status +import aiofiles +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi.responses import FileResponse +from PIL import Image from pydantic import BaseModel, Field from sqlalchemy import func as sqla_func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import get_settings from app.core.security import get_current_user, require_role, get_authenticated_user from app.database import get_db from app.models.tenant import Tenant @@ -39,6 +45,7 @@ class TenantOut(BaseModel): sso_enabled: bool = False sso_domain: str | None = None a2a_async_enabled: bool = False + logo_url: str | None = None created_at: datetime | None = None model_config = {"from_attributes": True} @@ -55,6 +62,42 @@ class TenantUpdate(BaseModel): a2a_async_enabled: bool | None = None +def _tenant_logo_dir() -> Path: + return Path(get_settings().AGENT_DATA_DIR) / "_tenant_logos" + + +def _tenant_logo_path(tenant_id: uuid.UUID) -> Path: + return _tenant_logo_dir() / f"{tenant_id}.png" + + +def _tenant_logo_url(tenant_id: uuid.UUID) -> str: + try: + mtime = int(_tenant_logo_path(tenant_id).stat().st_mtime) + except OSError: + mtime = int(datetime.utcnow().timestamp()) + return f"/api/tenants/{tenant_id}/logo?v={mtime}" + + +async def _get_updateable_tenant( + tenant_id: uuid.UUID, + current_user: User, + db: AsyncSession, +) -> Tenant: + if current_user.role == "org_admin": + if not current_user.tenant_id: + raise HTTPException(status_code=403, detail="Organization admin must belong to a company") + if current_user.tenant_id != tenant_id: + raise HTTPException(status_code=403, detail="Can only update your own company") + elif current_user.role != "platform_admin": + raise HTTPException(status_code=403, detail="Admin access required") + + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + return tenant + + # ─── Helpers ──────────────────────────────────────────── def _slugify(name: str) -> str: @@ -476,6 +519,61 @@ async def update_tenant( return TenantOut.model_validate(tenant) +@router.get("/{tenant_id}/logo") +async def get_tenant_logo(tenant_id: uuid.UUID): + """Serve a tenant logo. Logos are public UI assets, addressed by UUID.""" + path = _tenant_logo_path(tenant_id) + if not path.exists(): + raise HTTPException(status_code=404, detail="Logo not found") + return FileResponse(path, media_type="image/png") + + +@router.post("/{tenant_id}/logo", response_model=TenantOut) +async def upload_tenant_logo( + tenant_id: uuid.UUID, + file: UploadFile = File(...), + current_user: User = Depends(require_role("org_admin", "platform_admin")), + db: AsyncSession = Depends(get_db), +): + """Upload a cropped square company logo. + + The frontend crops to a 1:1 PNG before upload. The backend keeps a hard + 1 MB limit and stores the image outside git-managed source files. + """ + tenant = await _get_updateable_tenant(tenant_id, current_user, db) + if file.content_type not in {"image/png", "image/jpeg", "image/webp"}: + raise HTTPException(status_code=400, detail="Logo must be a PNG, JPEG, or WebP image") + + data = await file.read() + if len(data) > 1024 * 1024: + raise HTTPException(status_code=400, detail="Logo image must be 1 MB or smaller") + try: + image = Image.open(io.BytesIO(data)) + image.load() + except Exception as exc: + raise HTTPException(status_code=400, detail="Invalid image file") from exc + if image.width != image.height: + raise HTTPException(status_code=400, detail="Logo image must be a 1:1 square") + + output = io.BytesIO() + image.convert("RGBA").save(output, format="PNG", optimize=True) + png_data = output.getvalue() + if len(png_data) > 1024 * 1024: + raise HTTPException(status_code=400, detail="Logo image must be 1 MB or smaller after processing") + + logo_dir = _tenant_logo_dir() + logo_dir.mkdir(parents=True, exist_ok=True) + path = _tenant_logo_path(tenant_id) + async with aiofiles.open(path, "wb") as f: + await f.write(png_data) + + config = dict(tenant.im_config or {}) + config["logo_url"] = _tenant_logo_url(tenant_id) + tenant.im_config = config + await db.flush() + return TenantOut.model_validate(tenant) + + @router.put("/{tenant_id}/assign-user/{user_id}") async def assign_user_to_tenant( tenant_id: uuid.UUID, diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index b7817f739..b966c0f2e 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -54,3 +54,11 @@ class Tenant(Base): # A2A async communication (notify / task_delegate) # When False, all agent-to-agent messages use synchronous consult mode a2a_async_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + + @property + def logo_url(self) -> str | None: + """Tenant logo URL stored in flexible tenant config.""" + if isinstance(self.im_config, dict): + value = self.im_config.get("logo_url") + return value if isinstance(value, str) and value else None + return None diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 0ae5d8e34..71aa343c8 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -104,6 +104,7 @@ class TenantChoice(BaseModel): tenant_id: uuid.UUID | None tenant_name: str tenant_slug: str + logo_url: str | None = None class MultiTenantResponse(BaseModel): diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index f47375aa6..db6f14d50 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -1453,6 +1453,18 @@ "title": "Company Name", "placeholder": "Enter company name" }, + "logo": { + "title": "Company Logo", + "description": "Used in the sidebar workspace switcher and company selection menus.", + "upload": "Upload logo", + "hint": "PNG, JPG, or WebP. Max 1 MB. You will crop it to a square before saving.", + "cropTitle": "Crop company logo", + "zoom": "Zoom", + "tooLarge": "Logo image must be 1 MB or smaller.", + "invalidType": "Please choose an image file.", + "croppedTooLarge": "Cropped logo is still larger than 1 MB.", + "uploadFailed": "Failed to upload logo." + }, "companyIntro": { "title": "Company Intro", "description": "Describe your company's mission, products, and culture. This information will be included as context in every Agent conversation.", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 77446d99d..310d4d7c1 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -1393,6 +1393,18 @@ "title": "公司名称", "placeholder": "输入公司名称" }, + "logo": { + "title": "公司标识", + "description": "用于左侧公司切换入口和公司选择菜单。", + "upload": "上传标识", + "hint": "支持 PNG、JPG 或 WebP,最大 1MB。保存前需要裁剪为正方形。", + "cropTitle": "裁剪公司标识", + "zoom": "缩放", + "tooLarge": "Logo 图片不能大于 1MB。", + "invalidType": "请选择图片文件。", + "croppedTooLarge": "裁剪后的 Logo 仍然大于 1MB。", + "uploadFailed": "Logo 上传失败。" + }, "companyIntro": { "title": "公司简介", "description": "描述你的公司使命、产品和文化。此信息会作为上下文包含在每次 Agent 对话中。", diff --git a/frontend/src/index.css b/frontend/src/index.css index 1ae1fdec8..6a74fd449 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -367,7 +367,7 @@ select:focus { } .sidebar-top { - padding-top: var(--space-4); + padding-top: var(--space-2); flex-shrink: 0; } @@ -413,6 +413,606 @@ select:focus { letter-spacing: -0.02em; } +.sidebar-workspace-row { + position: relative; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); + margin-bottom: var(--space-2); +} + +.workspace-switcher-trigger { + min-width: 0; + height: 34px; + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.workspace-switcher-trigger:hover, +.workspace-switcher-trigger.open { + background: var(--bg-hover); + border-color: var(--border-subtle); +} + +.workspace-switcher-trigger:focus-visible { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px var(--accent-subtle); +} + +.workspace-switcher-avatar { + width: 26px; + height: 26px; + flex: 0 0 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + background: #eeeeec; + border: none; + color: #6f6f68; + font-size: 15px; + font-weight: 700; + line-height: 1; + letter-spacing: 0; +} + +.workspace-switcher-avatar img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.workspace-switcher-avatar.tone-1 { + background: #eeeeec; + color: #6f6f68; +} + +.workspace-switcher-avatar.tone-2 { + background: #f0eeeb; + color: #776b5f; +} + +.workspace-switcher-avatar.tone-3 { + background: #ecefee; + color: #61716d; +} + +.workspace-switcher-avatar.tone-4 { + background: #efeeee; + color: #725f63; +} + +.workspace-switcher-avatar.tone-5 { + background: #f1efea; + color: #766c58; +} + +.workspace-switcher-avatar.tone-6 { + background: #eceef1; + color: #5f6876; +} + +[data-theme="dark"] .workspace-switcher-avatar { + background: #2a2a32; + color: #aaaab7; +} + +[data-theme="dark"] .workspace-switcher-avatar.tone-1 { + background: #2a2a32; + color: #aaaab7; +} + +[data-theme="dark"] .workspace-switcher-avatar.tone-2 { + background: #302d2a; + color: #b2a89e; +} + +[data-theme="dark"] .workspace-switcher-avatar.tone-3 { + background: #29302e; + color: #9fb4ad; +} + +[data-theme="dark"] .workspace-switcher-avatar.tone-4 { + background: #312c2e; + color: #b4a2a8; +} + +[data-theme="dark"] .workspace-switcher-avatar.tone-5 { + background: #302f2a; + color: #b4ad9c; +} + +[data-theme="dark"] .workspace-switcher-avatar.tone-6 { + background: #292d33; + color: #a3adbb; +} + +.workspace-switcher-name { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + font-size: 14px; + font-weight: 600; +} + +.workspace-switcher-chevron { + flex: 0 0 auto; + color: var(--text-tertiary); + transition: transform var(--transition-fast); +} + +.workspace-switcher-trigger.open .workspace-switcher-chevron { + transform: rotate(180deg); +} + +.tenant-switcher-popover { + position: fixed; + top: 82px; + left: var(--space-4); + width: 304px; + max-height: min(520px, calc(100vh - 96px)); + overflow: hidden; + z-index: 10020; + padding: 10px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + transform-origin: top left; + animation: tenantDropdownIn 0.16s ease; + display: flex; + flex-direction: column; +} + +.tenant-switcher-label { + flex-shrink: 0; + padding: 8px 12px 7px; + font-size: 12px; + font-weight: 500; + color: var(--text-tertiary); +} + +.tenant-switcher-search { + flex-shrink: 0; + height: 34px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 9px; + margin: 2px 2px 8px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-secondary); + color: var(--text-tertiary); +} + +.tenant-switcher-search input { + min-width: 0; + flex: 1; + height: 100%; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + outline: none; + box-shadow: none; + padding: 0; +} + +.tenant-switcher-search input:focus { + box-shadow: none; +} + +.tenant-switcher-search button { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; +} + +.tenant-switcher-search button:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tenant-switcher-list { + min-height: 0; + max-height: 286px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + padding-right: 2px; +} + +.tenant-switcher-list::-webkit-scrollbar { + width: 4px; +} + +.tenant-switcher-list::-webkit-scrollbar-track { + background: transparent; +} + +.tenant-switcher-list::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 4px; +} + +.tenant-switcher-item, +.tenant-switcher-action { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + min-height: 38px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + text-align: left; + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); +} + +.tenant-switcher-item:hover, +.tenant-switcher-action:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tenant-switcher-item.active { + background: var(--bg-tertiary); + border-color: var(--border-subtle); + color: var(--text-primary); + font-weight: 600; +} + +.tenant-switcher-icon { + width: 22px; + flex: 0 0 22px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); +} + +.tenant-switcher-name { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tenant-switcher-empty { + padding: 16px 10px; + color: var(--text-tertiary); + font-size: 13px; + text-align: center; +} + +.tenant-switcher-divider { + flex-shrink: 0; + height: 1px; + background: var(--border-subtle); + margin: 10px 0; +} + +.tenant-setup-modal-backdrop { + position: fixed; + inset: 0; + z-index: 10030; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.48); + backdrop-filter: blur(4px); +} + +.tenant-setup-modal { + width: min(440px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + overflow-y: auto; + padding: 22px; + background: var(--bg-primary); + border: 1px solid var(--border-subtle); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + gap: 18px; + animation: accountDropdownIn 0.16s ease; +} + +.tenant-setup-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.tenant-setup-modal-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.tenant-setup-modal-header p { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.45; + color: var(--text-tertiary); +} + +.tenant-setup-modal-header button { + width: 30px; + height: 30px; + flex: 0 0 30px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-tertiary); + cursor: pointer; +} + +.tenant-setup-modal-header button:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tenant-setup-error { + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--error-subtle); + color: var(--error); + font-size: 12px; +} + +.tenant-setup-section { + display: flex; + flex-direction: column; + gap: 9px; +} + +.tenant-setup-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.tenant-setup-row { + display: flex; + gap: 8px; +} + +.tenant-setup-row .form-input { + min-width: 0; + flex: 1; + height: 38px; + font-size: 13px; +} + +.tenant-setup-row .btn { + height: 38px; + padding: 0 16px; + font-size: 12px; +} + +.tenant-setup-divider { + display: flex; + align-items: center; + gap: 12px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; +} + +.tenant-setup-divider::before, +.tenant-setup-divider::after { + content: ''; + height: 1px; + flex: 1; + background: var(--border-subtle); +} + +.company-identity-logo-row { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 14px; +} + +.company-identity-logo-preview { + width: 48px; + height: 48px; + flex: 0 0 48px; + border-radius: 10px; + background: var(--bg-tertiary); + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + font-size: 24px; + font-weight: 700; +} + +.company-identity-logo-preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.tenant-logo-crop-backdrop { + position: fixed; + inset: 0; + z-index: 10040; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.52); + backdrop-filter: blur(4px); +} + +.tenant-logo-crop-modal { + width: min(420px, calc(100vw - 48px)); + padding: 22px; + border-radius: 12px; + border: 1px solid var(--border-subtle); + background: var(--bg-primary); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.32); + display: flex; + flex-direction: column; + gap: 16px; +} + +.tenant-logo-crop-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.tenant-logo-crop-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.tenant-logo-crop-header p { + margin: 5px 0 0; + color: var(--text-tertiary); + font-size: 12px; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tenant-logo-crop-header button { + width: 30px; + height: 30px; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 20px; + line-height: 1; +} + +.tenant-logo-crop-header button:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tenant-logo-crop-stage { + width: 240px; + height: 240px; + align-self: center; + position: relative; + overflow: hidden; + border-radius: 12px; + background: + linear-gradient(45deg, var(--bg-secondary) 25%, transparent 25%), + linear-gradient(-45deg, var(--bg-secondary) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--bg-secondary) 75%), + linear-gradient(-45deg, transparent 75%, var(--bg-secondary) 75%); + background-color: var(--bg-tertiary); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0; + cursor: grab; + touch-action: none; + user-select: none; +} + +.tenant-logo-crop-stage:active { + cursor: grabbing; +} + +.tenant-logo-crop-stage::after { + content: ''; + position: absolute; + inset: 0; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 12px; + pointer-events: none; +} + +[data-theme="light"] .tenant-logo-crop-stage::after { + border-color: rgba(0, 0, 0, 0.16); +} + +.tenant-logo-crop-stage img { + position: absolute; + left: 50%; + top: 50%; + transform-origin: center center; + translate: -50% -50%; + max-width: none; + user-select: none; + pointer-events: none; +} + +.tenant-logo-crop-controls { + display: flex; + align-items: center; + gap: 12px; + color: var(--text-secondary); + font-size: 12px; +} + +.tenant-logo-crop-controls input { + flex: 1; +} + +.tenant-logo-crop-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +@keyframes tenantDropdownIn { + from { + opacity: 0; + transform: translateY(-6px) scaleY(0.96); + } + to { + opacity: 1; + transform: translateY(0) scaleY(1); + } +} + .sidebar-section { padding: var(--space-1) var(--space-3); margin-top: var(--space-2); @@ -739,6 +1339,26 @@ select:focus { gap: var(--space-2); } +.sidebar.collapsed .sidebar-workspace-row { + justify-content: center; + flex-direction: column; + padding: var(--space-3) 0; + gap: var(--space-2); +} + +.sidebar.collapsed .workspace-switcher-trigger { + width: 36px; + flex: 0 0 36px; + justify-content: center; + padding: 6px; +} + +.sidebar.collapsed .workspace-switcher-name, +.sidebar.collapsed .workspace-switcher-chevron, +.sidebar.collapsed .tenant-switcher-popover { + display: none; +} + .sidebar.collapsed .sidebar-logo-text { display: none; } diff --git a/frontend/src/pages/EnterpriseSettings.tsx b/frontend/src/pages/EnterpriseSettings.tsx index 82ee97f9c..ed195c145 100644 --- a/frontend/src/pages/EnterpriseSettings.tsx +++ b/frontend/src/pages/EnterpriseSettings.tsx @@ -1642,7 +1642,234 @@ function SkillsTab() { -// ─── Company Name Editor ─────────────────────────── +// ─── Company Identity Editor ─────────────────────── +function CompanyLogoCropModal({ imageUrl, imageName, onCancel, onSave }: { + imageUrl: string; + imageName: string; + onCancel: () => void; + onSave: (blob: Blob) => void; +}) { + const { t } = useTranslation(); + const imgRef = useRef(null); + const [naturalSize, setNaturalSize] = useState({ width: 1, height: 1 }); + const [zoom, setZoom] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [dragStart, setDragStart] = useState<{ x: number; y: number; ox: number; oy: number } | null>(null); + const cropSize = 240; + + const clampOffset = (next: { x: number; y: number }, nextZoom = zoom) => { + const baseScale = Math.max(cropSize / naturalSize.width, cropSize / naturalSize.height); + const displayW = naturalSize.width * baseScale * nextZoom; + const displayH = naturalSize.height * baseScale * nextZoom; + const maxX = Math.max(0, (displayW - cropSize) / 2); + const maxY = Math.max(0, (displayH - cropSize) / 2); + return { + x: Math.min(maxX, Math.max(-maxX, next.x)), + y: Math.min(maxY, Math.max(-maxY, next.y)), + }; + }; + + const handleSave = () => { + const img = imgRef.current; + if (!img) return; + const outputSize = 512; + const ratio = outputSize / cropSize; + const baseScale = Math.max(cropSize / naturalSize.width, cropSize / naturalSize.height); + const displayW = naturalSize.width * baseScale * zoom; + const displayH = naturalSize.height * baseScale * zoom; + const dx = ((cropSize - displayW) / 2 + offset.x) * ratio; + const dy = ((cropSize - displayH) / 2 + offset.y) * ratio; + const canvas = document.createElement('canvas'); + canvas.width = outputSize; + canvas.height = outputSize; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, outputSize, outputSize); + ctx.drawImage(img, dx, dy, displayW * ratio, displayH * ratio); + canvas.toBlob((blob) => { + if (blob) onSave(blob); + }, 'image/png'); + }; + + return ( +
+
e.stopPropagation()}> +
+
+

{t('enterprise.logo.cropTitle', 'Crop company logo')}

+

{imageName}

+
+ +
+
{ + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + setDragStart({ x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y }); + }} + onPointerMove={e => { + if (!dragStart) return; + setOffset(clampOffset({ + x: dragStart.ox + e.clientX - dragStart.x, + y: dragStart.oy + e.clientY - dragStart.y, + })); + }} + onPointerUp={() => setDragStart(null)} + onPointerCancel={() => setDragStart(null)} + > + { + const img = e.currentTarget; + setNaturalSize({ width: img.naturalWidth || 1, height: img.naturalHeight || 1 }); + setOffset({ x: 0, y: 0 }); + setZoom(1); + }} + style={{ + width: `${naturalSize.width * Math.max(cropSize / naturalSize.width, cropSize / naturalSize.height)}px`, + height: `${naturalSize.height * Math.max(cropSize / naturalSize.width, cropSize / naturalSize.height)}px`, + transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`, + }} + /> +
+
+ {t('enterprise.logo.zoom', 'Zoom')} + { + const nextZoom = Number(e.target.value); + setZoom(nextZoom); + setOffset(prev => clampOffset(prev, nextZoom)); + }} + /> +
+
+ + +
+
+
+ ); +} + +function CompanyLogoEditor() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const tenantId = localStorage.getItem('current_tenant_id') || ''; + const [name, setName] = useState(''); + const [logoUrl, setLogoUrl] = useState(''); + const [logoError, setLogoError] = useState(''); + const [logoSaving, setLogoSaving] = useState(false); + const [cropSource, setCropSource] = useState<{ url: string; name: string } | null>(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (!tenantId) return; + fetchJson(`/tenants/${tenantId}`) + .then(d => { + if (d?.name) setName(d.name); + setLogoUrl(d?.logo_url || ''); + }) + .catch(() => { }); + }, [tenantId]); + + const handleLogoFile = (file: File | undefined) => { + setLogoError(''); + if (!file) return; + if (file.size > 1024 * 1024) { + setLogoError(t('enterprise.logo.tooLarge', 'Logo image must be 1 MB or smaller.')); + return; + } + if (!file.type.startsWith('image/')) { + setLogoError(t('enterprise.logo.invalidType', 'Please choose an image file.')); + return; + } + setCropSource({ url: URL.createObjectURL(file), name: file.name }); + }; + + const uploadCroppedLogo = async (blob: Blob) => { + if (!tenantId) return; + setLogoError(''); + if (blob.size > 1024 * 1024) { + setLogoError(t('enterprise.logo.croppedTooLarge', 'Cropped logo is still larger than 1 MB.')); + return; + } + setLogoSaving(true); + try { + const form = new FormData(); + form.append('file', blob, 'company-logo.png'); + const res = await fetch(`/api/tenants/${tenantId}/logo`, { + method: 'POST', + headers: { Authorization: `Bearer ${localStorage.getItem('token') || ''}` }, + body: form, + }); + if (!res.ok) { + throw new Error(t('enterprise.logo.uploadFailed', 'Failed to upload logo.')); + } + const tenant = await res.json(); + setLogoUrl(tenant.logo_url || ''); + setCropSource(null); + qc.invalidateQueries({ queryKey: ['tenant', tenantId] }); + qc.invalidateQueries({ queryKey: ['my-tenants'] }); + } catch (e: any) { + setLogoError(e.message || t('enterprise.logo.uploadFailed', 'Failed to upload logo.')); + } finally { + setLogoSaving(false); + } + }; + + return ( +
+
+ {t('enterprise.logo.title', 'Company Logo')} +
+
+ {t('enterprise.logo.description', 'Used in the sidebar workspace switcher and company selection menus.')} +
+
+
+ {logoUrl ? : {(Array.from(name.trim())[0] as string | undefined)?.toUpperCase() || 'C'}} +
+
+ { + handleLogoFile(e.target.files?.[0]); + e.currentTarget.value = ''; + }} + /> + +
+ {t('enterprise.logo.hint', 'PNG, JPG, or WebP. Max 1 MB. You will crop it to a square before saving.')} +
+ {logoError &&
{logoError}
} +
+
+ {cropSource && ( + setCropSource(null)} + onSave={uploadCroppedLogo} + /> + )} +
+ ); +} + function CompanyNameEditor() { const { t } = useTranslation(); const qc = useQueryClient(); @@ -1666,6 +1893,8 @@ function CompanyNameEditor() { method: 'PUT', body: JSON.stringify({ name: name.trim() }), }); qc.invalidateQueries({ queryKey: ['tenants'] }); + qc.invalidateQueries({ queryKey: ['tenant', tenantId] }); + qc.invalidateQueries({ queryKey: ['my-tenants'] }); setSaved(true); setTimeout(() => setSaved(false), 2000); } catch (e) { } @@ -3146,6 +3375,7 @@ export default function EnterpriseSettings() { {/* ── Company Management ── */} {activeTab === 'info' && (
+
diff --git a/frontend/src/pages/Layout.tsx b/frontend/src/pages/Layout.tsx index 34f7f825f..8a229b895 100644 --- a/frontend/src/pages/Layout.tsx +++ b/frontend/src/pages/Layout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { Outlet, NavLink, useNavigate, useMatch } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -26,9 +26,9 @@ import { IconArrowUpRight, IconBuilding, IconChevronUp, - IconSwitchHorizontal, IconChevronRight, IconCheck, + IconChevronDown, } from '@tabler/icons-react'; import { useAppStore } from '../stores'; @@ -79,6 +79,14 @@ const getAgentBadgeStatus = (agent: any): string | null => { return null; }; +const getWorkspaceAvatarTone = (name: string): number => { + let hash = 0; + for (const char of name) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + return (hash % 6) + 1; +}; + /* ────── Account Settings Modal ────── */ function AccountSettingsModal({ user, onClose, isChinese }: { user: any; onClose: () => void; isChinese: boolean }) { const { setUser } = useAuthStore(); @@ -252,12 +260,14 @@ export default function Layout() { const [notifCategory, setNotifCategory] = useState('all'); const [selectedNotification, setSelectedNotification] = useState(null); const [showTenantMenu, setShowTenantMenu] = useState(false); - const [showJoinCreateForm, setShowJoinCreateForm] = useState(false); + const [showTenantSetupModal, setShowTenantSetupModal] = useState(false); + const [tenantSearch, setTenantSearch] = useState(''); const [joinInviteCode, setJoinInviteCode] = useState(''); const [createCompanyName, setCreateCompanyName] = useState(''); const [tenantFormLoading, setTenantFormLoading] = useState(false); const [tenantFormError, setTenantFormError] = useState(''); const [allowSelfCreate, setAllowSelfCreate] = useState(true); + const tenantSwitcherRef = useRef(null); // Notification polling const { data: unreadCount = 0 } = useQuery({ @@ -269,7 +279,7 @@ export default function Layout() { refetchInterval: 30000, enabled: !!user, }); - const { data: notifications = [], refetch: refetchNotifications } = useQuery({ + const { data: notifications = [] } = useQuery({ queryKey: ['notifications', notifCategory], queryFn: () => fetchJson(`/notifications?limit=50${notifCategory !== 'all' ? `&category=${notifCategory}` : ''}`), enabled: !!user && showNotifications, @@ -327,11 +337,15 @@ export default function Layout() { // Open the tenant switcher modal — also fetch self-create config const openTenantModal = () => { setShowTenantMenu(true); - setShowJoinCreateForm(false); + setTenantSearch(''); + }; + + const openTenantSetupModal = () => { + setShowTenantMenu(false); + setShowTenantSetupModal(true); setJoinInviteCode(''); setCreateCompanyName(''); setTenantFormError(''); - // Fetch self-create config tenantApi.registrationConfig().then((d: any) => { setAllowSelfCreate(d.allow_self_create_company); }).catch(() => {}); @@ -354,6 +368,7 @@ export default function Layout() { if (token) setAuth(me, token); } setShowTenantMenu(false); + setShowTenantSetupModal(false); window.location.reload(); } catch (err: any) { setTenantFormError(err.message || 'Failed to join company'); @@ -379,6 +394,7 @@ export default function Layout() { if (token) setAuth(me, token); } setShowTenantMenu(false); + setShowTenantSetupModal(false); window.location.reload(); } catch (err: any) { setTenantFormError(err.message || 'Failed to create company'); @@ -423,6 +439,21 @@ export default function Layout() { // Use user's own tenant_id directly (no switching) const currentTenant = user?.tenant_id || ''; + const currentTenantName = useMemo(() => { + const tenant = (myTenants as any[]).find((item: any) => item.tenant_id === currentTenant); + return tenant?.tenant_name || (isChinese ? '当前公司' : 'Current Company'); + }, [currentTenant, isChinese, myTenants]); + const currentTenantLogoUrl = useMemo(() => { + const tenant = (myTenants as any[]).find((item: any) => item.tenant_id === currentTenant); + return tenant?.logo_url || ''; + }, [currentTenant, myTenants]); + const currentTenantInitial = (Array.from(currentTenantName.trim())[0] as string | undefined)?.toUpperCase() || 'C'; + const currentTenantAvatarTone = useMemo(() => getWorkspaceAvatarTone(currentTenantName), [currentTenantName]); + const filteredTenants = useMemo(() => { + const query = tenantSearch.trim().toLowerCase(); + if (!query) return myTenants as any[]; + return (myTenants as any[]).filter((tenant: any) => (tenant.tenant_name || '').toLowerCase().includes(query)); + }, [myTenants, tenantSearch]); // Keep tenant in localStorage for other components that read it useEffect(() => { @@ -501,7 +532,9 @@ export default function Layout() { const t = e.target as Node; if (accountMenuRef.current?.contains(t)) return; if (langSubmenuPortalRef.current?.contains(t)) return; + if (tenantSwitcherRef.current?.contains(t)) return; setShowAccountMenu(false); + setShowTenantMenu(false); }; if (showAccountMenu || showTenantMenu) document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); @@ -538,15 +571,99 @@ export default function Layout() {
- {/* Tenant Switcher Modal */} - {showTenantMenu && ( -
setShowTenantMenu(false)}> -
e.stopPropagation()}> - {/* Header */} -
-

{isChinese ? '切换企业' : 'Switch Organization'}

- -
- - {/* Tenant List */} -
- {myTenants.map((tenant: any) => ( - - ))} + {showTenantSetupModal && ( +
setShowTenantSetupModal(false)}> +
e.stopPropagation()}> +
+
+

{isChinese ? '创建或加入新公司' : 'Create or Join Company'}

+

{isChinese ? '加入已有公司,或创建一个新的工作空间。' : 'Join an existing company or start a new workspace.'}

+
+
- {/* Divider */} -
+ {tenantFormError &&
{tenantFormError}
} - {/* Join/Create Toggle */} - {!showJoinCreateForm ? ( - - ) : ( -
- {/* Error message */} - {tenantFormError && ( -
{tenantFormError}
- )} +
+
{isChinese ? '通过邀请码加入' : 'Join via invitation code'}
+
+ setJoinInviteCode(e.target.value)} + placeholder={isChinese ? '输入邀请码' : 'Enter invitation code'} + /> + +
+
- {/* Join Company */} -
-
- {isChinese ? '通过邀请码加入' : 'Join via Invitation Code'} -
-
+ {allowSelfCreate && ( + <> +
{isChinese ? '或者' : 'OR'}
+ +
{isChinese ? '创建新公司' : 'Create a new company'}
+
setJoinInviteCode(e.target.value)} - placeholder={isChinese ? '输入邀请码' : 'Enter invitation code'} - style={{ flex: 1, fontSize: '13px', textTransform: 'uppercase', letterSpacing: '1px', fontFamily: 'monospace' }} + value={createCompanyName} + onChange={e => setCreateCompanyName(e.target.value)} + placeholder={isChinese ? '公司名称' : 'Company name'} /> -
- - {/* Create Company */} - {allowSelfCreate && ( - <> -
-
- {isChinese ? '或者' : 'OR'} -
-
-
-
- {isChinese ? '创建新公司' : 'Create a New Company'} -
-
- setCreateCompanyName(e.target.value)} - placeholder={isChinese ? '公司名称' : 'Company name'} - style={{ flex: 1, fontSize: '13px' }} - /> - -
-
- - )} - - {/* Back link */} -
- -
-
+ )}