Compare commits

..

52 Commits

Author SHA1 Message Date
openhands
9c061d178b refactor(auth): simplify OSS authorization to bare minimum
OSS module (openhands/server/auth/):
- Permission enum only
- Single no-op require_permission() that returns user_id
- No roles, no ROLE_PERMISSIONS, no helper methods

Enterprise module (enterprise/server/auth/authorization.py):
- Imports Permission from OSS module
- Defines RoleName enum and ROLE_PERMISSIONS locally
- Implements all authorization logic: require_permission(),
  require_org_role(), has_permission(), get_role_permissions(), etc.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-17 01:15:08 +00:00
openhands
cc7e002ffe feat(auth): add OSS no-op authorization with SAAS override
- Create openhands/server/auth/ module with no-op authorization
  - Permission and RoleName enums defined in OSS module
  - ROLE_PERMISSIONS mapping defined in OSS module
  - No-op require_permission() and require_org_role() dependencies
  - All authorization checks pass in OSS mode

- Update enterprise authorization to import from OSS module
  - Import Permission, RoleName, ROLE_PERMISSIONS from OSS
  - Import get_role_permissions() helper from OSS
  - Override require_permission() and require_org_role() with real checks
  - Enterprise performs actual permission validation in SAAS mode

This allows the same authorization decorators to be used in both modes:
- OSS mode: All checks pass (no-op)
- SAAS mode: Real permission checks are enforced

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-16 23:44:39 +00:00
openhands
d058b64597 feat(auth): implement permission-based authorization
- Add Permission enum with 15 granular permissions
- Add RoleName enum for owner, admin, member roles
- Add ROLE_PERMISSIONS mapping with correct permission sets per role
- Add require_permission() FastAPI dependency for permission-based access control
- Add helper functions: get_role_permissions(), has_permission()

Permission mapping:
- Member: manage_secrets, manage_mcp, manage_integrations,
  manage_application_settings, manage_api_keys, view_llm_settings
- Admin: All member permissions + edit_llm_settings, view_billing,
  add_credits, invite_user_to_organization, change_user_role:member,
  change_user_role:admin
- Owner: All admin permissions + change_user_role:owner,
  change_organization_name, delete_organization

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-16 23:09:16 +00:00
Chuck Butkus
954c922a98 Merge branch 'main' into feature/role-based-authorization 2026-02-16 14:23:31 -05:00
mamoodi
663ace4b39 Add saas-rel* branch pattern to ghcr-build workflow (#12888)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-16 12:27:37 -05:00
Hiep Le
2d085a6e0a fix(frontend): add auto-scroll when new messages arrive in chat (#12885) 2026-02-16 23:46:14 +07:00
Hiep Le
8b7112abe8 refactor(frontend): hide planning preview component when plan content is empty (#12879) 2026-02-16 18:35:20 +07:00
Hiep Le
34547ba947 fix(backend): enable byor key export after purchasing credits (#12862) 2026-02-16 17:02:06 +07:00
Graham Neubig
5f958ab60d fix: suppress alembic INFO logs before import to prevent Datadog misclassification (#12691)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 14:32:39 -05:00
Hiep Le
d7656bf1c9 refactor(backend): rename user role to member across the system (#12853) 2026-02-13 00:45:47 +07:00
Tim O'Farrell
2bc107564c Support list_files and get_trajectory for nested conversation managers (#12850)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-12 10:39:00 -07:00
Tim O'Farrell
85eb1e1504 Check event types before making expensive API calls in GitHub webhook handler (#12819)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 09:33:59 -07:00
OpenHands Bot
cd235cc8c7 Bump SDK packages to v1.11.4 (#12839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-02-11 10:55:46 -07:00
Graham Neubig
40f52dfabc Use lowercase minimax-m2.5 for consistency (#12840)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 01:29:17 +08:00
Hiep Le
bab7bf85e8 fix(backend): prevent org deletion from setting current_org_id to NULL (#12817) 2026-02-12 00:15:21 +07:00
Hiep Le
c856537f65 refactor(backend): update the patch organization api to support organization name updates (#12834) 2026-02-12 00:08:43 +07:00
Graham Neubig
736f5b2255 Add MiniMax-M2.5 model support (#12835)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-11 16:57:22 +00:00
chuckbutkus
c1d9d11772 Log all exceptions in get_user() when authentication fails (#12836)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-11 11:49:13 -05:00
sp.wack
85244499fe fix(frontend): performance and loading state bugs (#12821) 2026-02-11 15:34:52 +00:00
Hiep Le
c55084e223 fix(backend): read RECAPTCHA_SITE_KEY from environment in V1 web client config (#12830) 2026-02-11 18:59:52 +07:00
Tim O'Farrell
e3bb75deb4 fix(enterprise): use poetry.lock for reproducible dependency builds (#12820)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-11 04:51:12 -07:00
Hiep Le
1948200762 chore: update sdk to the latest version (#12811) 2026-02-11 12:57:08 +07:00
Tim O'Farrell
affe0af361 Add debug logging for sandbox startup health checks (#12814)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-10 07:23:43 -07:00
Hiep Le
f20c956196 feat(backend): implement org member patch api (#12800) 2026-02-10 20:01:24 +07:00
Alexander Grattan
4a089a3a0d fix(docs): update Gray Swan API links and onboarding instructions in security README (#12809) 2026-02-10 10:14:49 +00:00
Hiep Le
aa0b2d0b74 feat(backend): add api for switching between orgs (#12799) 2026-02-10 14:22:52 +07:00
Hiep Le
bef9b80b9d fix(frontend): add missing border radius to conversation loading on first load (#12796) 2026-02-09 21:36:07 +07:00
Graham Neubig
c4a90b1f89 Fix Resend ValidationError by adding email validation (#12511)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-08 09:47:39 -05:00
sp.wack
0d13c57d9f feat(backend): org get me route (#12760)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-07 16:11:25 +07:00
Graham Neubig
b3422f1275 Add PR Review by OpenHands workflow (#12784)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 17:26:16 -05:00
Xingyao Wang
f139a9970b feat: add SANDBOX_STARTUP_GRACE_SECONDS env var for configurable startup timeout (#12741)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-07 06:12:29 +08:00
Jamie Chicago
54d156122c Add automated PR review workflow using OpenHands (#12698)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2026-02-06 19:02:55 +00:00
Tim O'Farrell
ac072bf686 feat(frontend): change alert banner from solid background to border style (#12783)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 18:05:29 +00:00
Hiep Le
a53812c029 feat(backend): develop delete /api/organizations/{orgid}/members/{userid} api (#12734) 2026-02-07 00:50:47 +07:00
Tim O'Farrell
1d1c0925b5 refactor: Move check_byor_export_enabled to OrgService and add tests (PR #12753 followup) (#12782)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 17:03:03 +00:00
Hiep Le
872f41e3c0 feat(backend): implement get /api/organizations/{orgId}/members api (#12735) 2026-02-06 23:47:30 +07:00
Tim O'Farrell
d43ff82534 feat: Add BYOR export flag to org for LLM key access control (#12753)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-06 09:30:12 -07:00
huangkevin-apr
8cd8c011b2 fix(a11y): Add aria-label to Sidebar component (#12728) 2026-02-06 22:32:52 +07:00
Tim O'Farrell
5c68b10983 (Frontend) Migrate to new /api/v1/web-client/config endpoint (#12479)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-06 08:31:40 -07:00
Graham Neubig
a97fad1976 fix: Add PostHog error tracking for V1 AgentErrorEvent and ConversationErrorEvent (#12543)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 09:51:01 -05:00
Graham Neubig
4c3542a91c fix: use appropriate log level for webhook installation results (#12493)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 09:01:37 -05:00
Tim O'Farrell
f460057f58 chore: add deprecation notices to all runtime directory files (#12772)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 05:15:02 -07:00
MkDev11
4fa2ad0f47 fix: add exponential backoff retry for env var export when bash session is busy (#12748)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
2026-02-06 05:07:17 -07:00
Hiep Le
dd8be12809 feat(backend): return is_personal field in OrgResponse (#12777) 2026-02-06 19:01:06 +07:00
Tim O'Farrell
89475095d9 Preload callback processor class to prevent Pydantic Deserialization Error (#12776) 2026-02-06 04:29:28 -07:00
Tim O'Farrell
05d5f8848a Fix V1 GitHub conversations failing to clone repository (#12775)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 03:08:55 -07:00
Hiep Le
ee2885eb0b feat: store plan.md file in appropriate configuration folders (#12713) 2026-02-06 16:09:39 +07:00
Tim O'Farrell
545257f870 Refactor: Add LLM provider utilities and improve API base URL detection (#12766)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-05 14:22:32 -07:00
openhands
c9cf142949 refactor: use Role class for role hierarchy in authorization
- Remove hardcoded OrgRole enum and ROLE_HIERARCHY dictionary
- Import Role class from storage.role module
- Update get_user_org_role() to return Role object instead of string
- Update has_required_role() to compare roles using rank field
- Lower rank = higher position in hierarchy (e.g., rank 1 > rank 2 > rank 3)
- Update require_org_role() to accept role name string and fetch Role from database
- Add error handling for missing role in database

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-04 04:18:15 +00:00
Chuck Butkus
7e0b6a39c1 Merge branch 'main' into feature/role-based-authorization 2026-02-03 22:43:19 -05:00
Chuck Butkus
268fa43ba9 Lint fix 2026-02-03 02:05:40 -05:00
openhands
ff14104db4 feat: Add role-based authorization for org-scoped endpoints
Implement role-based authorization using FastAPI dependencies that check
user roles (owner, admin, user) within organizations.

Changes:
- Add enterprise/server/auth/authorization.py with:
  - OrgRole enum for role types
  - Role hierarchy (owner > admin > user)
  - get_user_org_role() to retrieve user's role in an org
  - has_required_role() to check role hierarchy
  - require_org_role() dependency factory
  - Convenience dependencies: require_org_user, require_org_admin, require_org_owner

- Update enterprise/server/routes/orgs.py:
  - GET /{org_id}: require_org_user (any member can view)
  - PATCH /{org_id}: require_org_admin (admin or owner can update)
  - DELETE /{org_id}: require_org_owner (only owner can delete)

- Add comprehensive unit tests for authorization module

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-03 06:59:35 +00:00
242 changed files with 11570 additions and 1469 deletions

View File

@@ -9,6 +9,7 @@ on:
push:
branches:
- main
- "saas-rel-*"
tags:
- "*"
pull_request:

View File

@@ -0,0 +1,127 @@
---
name: PR Review by OpenHands
on:
# Use pull_request_target to allow fork PRs to access secrets when triggered by maintainers
# Security: This workflow runs when:
# 1. A new PR is opened (non-draft), OR
# 2. A draft PR is marked as ready for review, OR
# 3. A maintainer adds the 'review-this' label, OR
# 4. A maintainer requests openhands-agent or all-hands-bot as a reviewer
# Only users with write access can add labels or request reviews, ensuring security.
# The PR code is explicitly checked out for review, but secrets are only accessible
# because the workflow runs in the base repository context
pull_request_target:
types: [opened, ready_for_review, labeled, review_requested]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-review:
# Run when one of the following conditions is met:
# 1. A new non-draft PR is opened by a trusted contributor, OR
# 2. A draft PR is converted to ready for review by a trusted contributor, OR
# 3. 'review-this' label is added, OR
# 4. openhands-agent or all-hands-bot is requested as a reviewer
# Note: FIRST_TIME_CONTRIBUTOR PRs require manual trigger via label/reviewer request
if: |
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
github.event.label.name == 'review-this' ||
github.event.requested_reviewer.login == 'openhands-agent' ||
github.event.requested_reviewer.login == 'all-hands-bot'
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
LLM_MODEL: litellm_proxy/claude-sonnet-4-5-20250929
LLM_BASE_URL: https://llm-proxy.app.all-hands.dev
# PR context will be automatically provided by the agent script
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout software-agent-sdk repository
uses: actions/checkout@v5
with:
repository: OpenHands/software-agent-sdk
path: software-agent-sdk
- name: Checkout PR repository
uses: actions/checkout@v5
with:
# When using pull_request_target, explicitly checkout the PR branch
# This ensures we review the actual PR code (including fork PRs)
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
# Security: Don't persist credentials to prevent untrusted PR code from using them
persist-credentials: false
path: pr-repo
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install GitHub CLI
run: |
# Install GitHub CLI for posting review comments
sudo apt-get update
sudo apt-get install -y gh
- name: Install OpenHands dependencies
run: |
# Install OpenHands SDK and tools from local checkout
uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools
- name: Check required configuration
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
if [ -z "$LLM_API_KEY" ]; then
echo "Error: LLM_API_KEY secret is not set."
exit 1
fi
echo "PR Number: $PR_NUMBER"
echo "PR Title: $PR_TITLE"
echo "Repository: $REPO_NAME"
echo "LLM model: $LLM_MODEL"
if [ -n "$LLM_BASE_URL" ]; then
echo "LLM base URL: $LLM_BASE_URL"
fi
- name: Run PR review
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
GITHUB_TOKEN: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
run: |
# Change to the PR repository directory so agent can analyze the code
cd pr-repo
# Run the PR review script from the software-agent-sdk checkout
uv run python ../software-agent-sdk/examples/03_github_workflows/02_pr_review/agent_script.py
- name: Upload logs as artifact
uses: actions/upload-artifact@v5
if: always()
with:
name: openhands-pr-review-logs
path: |
*.log
output/
retention-days: 7

View File

@@ -23,12 +23,23 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Python packages with security fixes
RUN /app/.venv/bin/pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy google-cloud-recaptcha-enterprise && \
# Update packages with known CVE fixes
/app/.venv/bin/pip install --upgrade \
"mcp>=1.10.0" \
"pillow>=11.3.0"
# Install poetry and export before importing current code.
RUN /app/.venv/bin/pip install poetry poetry-plugin-export
# Install Python dependencies from poetry.lock for reproducible builds
# Copy lock files first for better Docker layer caching
COPY --chown=openhands:openhands enterprise/pyproject.toml enterprise/poetry.lock /tmp/enterprise/
RUN cd /tmp/enterprise && \
# Export only main dependencies with hashes for supply chain security
/app/.venv/bin/poetry export --only main -o requirements.txt && \
# Remove the local path dependency (openhands-ai is already in base image)
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
# Install pinned dependencies from lock file
/app/.venv/bin/pip install -r requirements.txt && \
# Cleanup - return to /app before removing /tmp/enterprise
cd /app && \
rm -rf /tmp/enterprise && \
/app/.venv/bin/pip uninstall -y poetry poetry-plugin-export
WORKDIR /app
COPY --chown=openhands:openhands --chmod=770 enterprise .

View File

@@ -145,11 +145,7 @@ class GithubManager(Manager):
).get('body', ''):
return False
if GithubFactory.is_eligible_for_conversation_starter(
message
) and self._user_has_write_access_to_repo(installation_id, repo_name, username):
await GithubFactory.trigger_conversation_starter(message)
# Check event types before making expensive API calls (e.g., _user_has_write_access_to_repo)
if not (
GithubFactory.is_labeled_issue(message)
or GithubFactory.is_issue_comment(message)
@@ -159,8 +155,17 @@ class GithubManager(Manager):
return False
logger.info(f'[GitHub] Checking permissions for {username} in {repo_name}')
user_has_write_access = self._user_has_write_access_to_repo(
installation_id, repo_name, username
)
return self._user_has_write_access_to_repo(installation_id, repo_name, username)
if (
GithubFactory.is_eligible_for_conversation_starter(message)
and user_has_write_access
):
await GithubFactory.trigger_conversation_starter(message)
return user_has_write_access
async def receive_message(self, message: Message):
self._confirm_incoming_source_type(message)

View File

@@ -167,17 +167,15 @@ async def install_webhook_on_resource(
scopes=SCOPES,
)
logger.info(
'Creating new webhook',
extra={
'webhook_id': webhook_id,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
log_extra = {
'webhook_id': webhook_id,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
}
if status == WebhookStatus.RATE_LIMITED:
logger.warning('Rate limited while creating webhook', extra=log_extra)
raise BreakLoopException()
if webhook_id:
@@ -191,9 +189,8 @@ async def install_webhook_on_resource(
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
},
)
logger.info(
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
)
logger.info('Created new webhook', extra=log_extra)
else:
logger.error('Failed to create webhook', extra=log_extra)
return webhook_id, status

View File

@@ -1,6 +1,6 @@
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -14,6 +14,7 @@ class ResolverUserContext(UserContext):
saas_user_auth: UserAuth,
):
self.saas_user_auth = saas_user_auth
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
return await self.saas_user_auth.get_user_id()
@@ -29,12 +30,26 @@ class ResolverUserContext(UserContext):
return UserInfo(id=user_id)
async def _get_provider_handler(self) -> ProviderHandler:
"""Get or create a ProviderHandler for git operations."""
if self._provider_handler is None:
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens is None:
raise ValueError('No provider tokens available')
user_id = await self.saas_user_auth.get_user_id()
self._provider_handler = ProviderHandler(
provider_tokens=provider_tokens, external_auth_id=user_id
)
return self._provider_handler
async def get_authenticated_git_url(
self, repository: str, is_optional: bool = False
) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
provider_handler = await self._get_provider_handler()
url = await provider_handler.get_authenticated_git_url(
repository, is_optional=is_optional
)
return url
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token string from git_provider_tokens

View File

@@ -1,10 +1,15 @@
import logging
import os
from logging.config import fileConfig
from alembic import context
from google.cloud.sql.connector import Connector
from sqlalchemy import create_engine
from storage.base import Base
# Suppress alembic.runtime.plugins INFO logs during import to prevent non-JSON logs in production
# These plugin setup messages would otherwise appear before logging is configured
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata

View File

@@ -0,0 +1,46 @@
"""Add byor_export_enabled flag to org table.
Revision ID: 091
Revises: 090
Create Date: 2025-01-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '091'
down_revision: Union[str, None] = '090'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add byor_export_enabled column to org table with default false
op.add_column(
'org',
sa.Column(
'byor_export_enabled',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
)
# Set byor_export_enabled to true for orgs that have completed billing sessions
op.execute(
sa.text("""
UPDATE org SET byor_export_enabled = TRUE
WHERE id IN (
SELECT DISTINCT org_id FROM billing_sessions
WHERE status = 'completed' AND org_id IS NOT NULL
)
""")
)
def downgrade() -> None:
op.drop_column('org', 'byor_export_enabled')

View File

@@ -0,0 +1,29 @@
"""Rename 'user' role to 'member' in role table.
Revision ID: 092
Revises: 091
Create Date: 2025-02-12 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '092'
down_revision: Union[str, None] = '091'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Rename 'user' role to 'member' for clarity
# This avoids confusion between the 'user' role and the 'user' entity/account
op.execute(sa.text("UPDATE role SET name = 'member' WHERE name = 'user'"))
def downgrade() -> None:
# Revert 'member' role back to 'user'
op.execute(sa.text("UPDATE role SET name = 'user' WHERE name = 'member'"))

210
enterprise/poetry.lock generated
View File

@@ -6102,14 +6102,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.11.1"
version = "1.11.4"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.11.1-py3-none-any.whl", hash = "sha256:28e3ca670114c7a936a33f2d193238fbdc75f429c4e0bb99a03b14e6c01663c9"},
{file = "openhands_agent_server-1.11.1.tar.gz", hash = "sha256:06eaf8b8eda4ca05de24751a7d269b22f611328c6cb2b4b91f2486011228b69a"},
{file = "openhands_agent_server-1.11.4-py3-none-any.whl", hash = "sha256:739bdb774dbfcd23d6e87ee6ee32bc0999f22300037506b6dd33e9ea67fa5c2a"},
{file = "openhands_agent_server-1.11.4.tar.gz", hash = "sha256:41247f7022a046eb50ca3b552bc6d12bfa9776e1bd27d0989da91b9f7ac77ca2"},
]
[package.dependencies]
@@ -6168,9 +6168,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.2"
openhands-agent-server = "1.11.1"
openhands-sdk = "1.11.1"
openhands-tools = "1.11.1"
openhands-agent-server = "1.11.4"
openhands-sdk = "1.11.4"
openhands-tools = "1.11.4"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
pathspec = ">=0.12.1"
@@ -6225,14 +6225,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.11.1"
version = "1.11.4"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.11.1-py3-none-any.whl", hash = "sha256:10ee0777286b149db21bdeeadb6d4c57f461da4049a4ba07576e7228b5c76c85"},
{file = "openhands_sdk-1.11.1.tar.gz", hash = "sha256:57f5884d0596a8659b7c0cdbe86ebaa74c810c4e2645fcff45f0113894dd9376"},
{file = "openhands_sdk-1.11.4-py3-none-any.whl", hash = "sha256:9f4607c5d94b56fbcd533207026ee892779dd50e29bce79277ff82454a4f76d5"},
{file = "openhands_sdk-1.11.4.tar.gz", hash = "sha256:4088744f6b8856eeab22d3bc17e47d1736ea7ced945c2fa126bd7d48c14bb313"},
]
[package.dependencies]
@@ -6253,14 +6253,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.11.1"
version = "1.11.4"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.11.1-py3-none-any.whl", hash = "sha256:0b64763def90dda5b6545a356a437437c2029ec9bc47a4e6dac5c06dea6a4e77"},
{file = "openhands_tools-1.11.1.tar.gz", hash = "sha256:2a71d2d0619ca631b3b7f5bd741bfdf97f7ebe6f96dc2540f79b9a688a6309fc"},
{file = "openhands_tools-1.11.4-py3-none-any.whl", hash = "sha256:efd721b73e87a0dac69171a76931363fa59fcde98107ca86081ee7bf0253673a"},
{file = "openhands_tools-1.11.4.tar.gz", hash = "sha256:80671b1ea8c85a5247a75ea2340ae31d76363e9c723b104699a9a77e66d2043c"},
]
[package.dependencies]
@@ -6851,103 +6851,103 @@ scramp = ">=1.4.5"
[[package]]
name = "pillow"
version = "12.1.0"
version = "12.1.1"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
groups = ["main", "test"]
files = [
{file = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"},
{file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"},
{file = "pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8"},
{file = "pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1"},
{file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda"},
{file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7"},
{file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a"},
{file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef"},
{file = "pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09"},
{file = "pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91"},
{file = "pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea"},
{file = "pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3"},
{file = "pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0"},
{file = "pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451"},
{file = "pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e"},
{file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84"},
{file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0"},
{file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b"},
{file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18"},
{file = "pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64"},
{file = "pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75"},
{file = "pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304"},
{file = "pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b"},
{file = "pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551"},
{file = "pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208"},
{file = "pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5"},
{file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661"},
{file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17"},
{file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670"},
{file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616"},
{file = "pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7"},
{file = "pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d"},
{file = "pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0"},
{file = "pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587"},
{file = "pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac"},
{file = "pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b"},
{file = "pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea"},
{file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c"},
{file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc"},
{file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644"},
{file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c"},
{file = "pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171"},
{file = "pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a"},
{file = "pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45"},
{file = "pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d"},
{file = "pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4"},
{file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0"},
{file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b"},
{file = "pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65"},
{file = "pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0"},
{file = "pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd"},
{file = "pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13"},
{file = "pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e"},
{file = "pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643"},
{file = "pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5"},
{file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de"},
{file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9"},
{file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a"},
{file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a"},
{file = "pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030"},
{file = "pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94"},
{file = "pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4"},
{file = "pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2"},
{file = "pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8"},
{file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924"},
{file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef"},
{file = "pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988"},
{file = "pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6"},
{file = "pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19"},
{file = "pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9"},
{file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"},
{file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"},
{file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"},
{file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"},
{file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"},
{file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"},
{file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"},
{file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"},
{file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"},
{file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"},
{file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"},
{file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"},
{file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"},
{file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"},
{file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"},
{file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"},
{file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"},
{file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"},
{file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"},
{file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"},
{file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"},
{file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"},
{file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"},
{file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"},
{file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"},
{file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"},
{file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"},
{file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"},
{file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"},
{file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"},
{file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"},
{file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"},
{file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"},
{file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"},
{file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"},
{file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"},
{file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"},
{file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"},
{file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"},
{file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"},
{file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"},
{file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"},
{file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"},
{file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"},
{file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"},
{file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"},
{file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"},
{file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"},
{file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"},
{file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"},
{file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"},
{file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"},
{file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"},
{file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"},
{file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"},
{file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"},
{file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"},
{file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"},
{file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"},
{file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"},
{file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"},
{file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"},
{file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"},
{file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"},
{file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"},
{file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"},
{file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"},
]
[package.extras]
@@ -14917,4 +14917,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "b5cbb1e25176845ac9f95650a802667e2f8be1a536e3e55a9269b5af5a42e3fc"
content-hash = "1cad6029269393af67155e930c72eae2c03da02e4b3a3699823f6168c14a4218"

View File

@@ -44,6 +44,12 @@ httpx = "*"
scikit-learn = "^1.7.0"
shap = "^0.48.0"
google-cloud-recaptcha-enterprise = "^1.24.0"
# Dependencies previously only in Dockerfile, now managed via poetry.lock
prometheus-client = "^0.24.0"
pandas = "^2.2.0"
numpy = "^2.2.0"
mcp = "^1.10.0"
pillow = "^12.1.0"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.3"

View File

@@ -78,8 +78,15 @@ base_app.include_router(shared_event_router)
# Add GitHub integration router only if GITHUB_APP_CLIENT_ID is set
if GITHUB_APP_CLIENT_ID:
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
from integrations.github.github_v1_callback_processor import ( # noqa: E402
GithubV1CallbackProcessor,
)
from server.routes.integration.github import github_integration_router # noqa: E402
# Bludgeon mypy into not deleting my import
logger.debug(f'Loaded {GithubV1CallbackProcessor.__name__}')
base_app.include_router(
github_integration_router
) # Add additional route for integration webhook events

View File

@@ -0,0 +1,320 @@
"""
Permission-based authorization dependencies for API endpoints (SAAS mode).
This module provides FastAPI dependencies for checking user permissions
within organizations. It uses a permission-based authorization model where
roles (owner, admin, member) are mapped to specific permissions.
This is the SAAS/enterprise implementation that performs real authorization
checks against the database.
Usage:
from server.auth.authorization import (
Permission,
require_permission,
require_org_role,
require_org_user,
require_org_admin,
require_org_owner,
)
@router.get('/{org_id}/settings')
async def get_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
):
# Only users with VIEW_LLM_SETTINGS permission can access
...
"""
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.auth import Permission
from openhands.server.user_auth import get_user_id
class RoleName(str, Enum):
"""Role names used in the system."""
OWNER = 'owner'
ADMIN = 'admin'
MEMBER = 'member'
# Permission mappings for each role
ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
RoleName.OWNER: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
Permission.VIEW_LLM_SETTINGS,
Permission.EDIT_LLM_SETTINGS,
Permission.VIEW_BILLING,
Permission.ADD_CREDITS,
# Organization Members
Permission.INVITE_USER_TO_ORGANIZATION,
Permission.CHANGE_USER_ROLE_MEMBER,
Permission.CHANGE_USER_ROLE_ADMIN,
Permission.CHANGE_USER_ROLE_OWNER,
# Organization Management (Owner only)
Permission.CHANGE_ORGANIZATION_NAME,
Permission.DELETE_ORGANIZATION,
]
),
RoleName.ADMIN: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
Permission.VIEW_LLM_SETTINGS,
Permission.EDIT_LLM_SETTINGS,
Permission.VIEW_BILLING,
Permission.ADD_CREDITS,
# Organization Members
Permission.INVITE_USER_TO_ORGANIZATION,
Permission.CHANGE_USER_ROLE_MEMBER,
Permission.CHANGE_USER_ROLE_ADMIN,
]
),
RoleName.MEMBER: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
# LLM Settings (View only)
Permission.VIEW_LLM_SETTINGS,
]
),
}
def get_role_permissions(role_name: str) -> frozenset[Permission]:
"""Get the permissions for a role."""
try:
role_enum = RoleName(role_name)
return ROLE_PERMISSIONS.get(role_enum, frozenset())
except ValueError:
return frozenset()
def get_user_org_role(user_id: str, org_id: UUID) -> Role | None:
"""
Get the user's role in an organization.
Args:
user_id: User ID (string that will be converted to UUID)
org_id: Organization ID
Returns:
Role object if user is a member, None otherwise
"""
from uuid import UUID as parse_uuid
org_member = OrgMemberStore.get_org_member(org_id, parse_uuid(user_id))
if not org_member:
return None
return RoleStore.get_role_by_id(org_member.role_id)
def has_permission(user_role: Role, permission: Permission) -> bool:
"""
Check if a role has a specific permission.
Args:
user_role: User's Role object
permission: Permission to check
Returns:
True if the role has the permission
"""
permissions = get_role_permissions(user_role.name)
return permission in permissions
def has_required_role(user_role: Role, required_role: Role) -> bool:
"""
Check if user's role meets or exceeds the required role.
Uses role hierarchy based on rank where lower rank = higher position
(e.g., rank 1 owner > rank 2 admin > rank 3 user).
Args:
user_role: User's actual Role object
required_role: Minimum required Role object
Returns:
True if user has sufficient permissions
"""
return user_role.rank <= required_role.rank
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
async def get_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
):
...
Args:
permission: The permission required to access the endpoint
Returns:
Dependency function that validates permission and returns user_id
"""
async def permission_checker(
org_id: UUID,
user_id: str | None = Depends(get_user_id),
) -> str:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_role = get_user_org_role(user_id, org_id)
if not user_role:
logger.warning(
'User not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
if not has_permission(user_role, permission):
logger.warning(
'Insufficient permissions',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
'required_permission': permission.value,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Requires {permission.value} permission',
)
return user_id
return permission_checker
def require_org_role(required_role_name: str):
"""
Factory function that creates a dependency to require a minimum org role.
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required role in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Role hierarchy is based on rank from the Role class, where
lower rank = higher position (e.g., rank 1 > rank 2 > rank 3).
Usage:
@router.get('/{org_id}/resource')
async def get_resource(
org_id: UUID,
user_id: str = Depends(require_org_role('user')),
):
...
Args:
required_role_name: Name of the minimum required role to access the endpoint
Returns:
Dependency function that validates role and returns user_id
"""
async def role_checker(
org_id: UUID,
user_id: str | None = Depends(get_user_id),
) -> str:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_role = get_user_org_role(user_id, org_id)
if not user_role:
logger.warning(
'User not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
required_role = RoleStore.get_role_by_name(required_role_name)
if not required_role:
logger.error(
'Required role not found in database',
extra={'required_role': required_role_name},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Role configuration error',
)
if not has_required_role(user_role, required_role):
logger.warning(
'Insufficient role permissions',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
'required_role': required_role_name,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Requires {required_role_name} role or higher',
)
return user_id
return role_checker
# Convenience dependencies for common role checks
require_org_user = require_org_role('user')
require_org_admin = require_org_role('admin')
require_org_owner = require_org_role('owner')

View File

@@ -15,6 +15,11 @@ IS_FEATURE_ENV = (
) # Does not include the staging deployment
IS_LOCAL_ENV = bool(HOST == 'localhost')
# Role name constants
ROLE_OWNER = 'owner'
ROLE_ADMIN = 'admin'
ROLE_MEMBER = 'member'
# Deprecated - billing margins are now handled internally in litellm
DEFAULT_BILLING_MARGIN = float(os.environ.get('DEFAULT_BILLING_MARGIN', '1.0'))

View File

@@ -6,6 +6,7 @@ from storage.api_key_store import ApiKeyStore
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
@@ -52,7 +53,6 @@ async def store_byor_key_in_db(user_id: str, key: str) -> None:
async def generate_byor_key(user_id: str) -> str | None:
"""Generate a new BYOR key for a user."""
try:
user = await UserStore.get_user_by_id_async(user_id)
if not user:
@@ -148,6 +148,26 @@ class LlmApiKeyResponse(BaseModel):
key: str | None
class ByorPermittedResponse(BaseModel):
permitted: bool
@api_router.get('/llm/byor/permitted', response_model=ByorPermittedResponse)
async def check_byor_permitted(user_id: str = Depends(get_user_id)):
"""Check if BYOR key export is permitted for the user's current org."""
try:
permitted = await OrgService.check_byor_export_enabled(user_id)
return {'permitted': permitted}
except Exception as e:
logger.exception(
'Error checking BYOR export permission', extra={'error': str(e)}
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to check BYOR export permission',
)
@api_router.post('', response_model=ApiKeyCreateResponse)
async def create_api_key(key_data: ApiKeyCreate, user_id: str = Depends(get_user_id)):
"""Create a new API key for the authenticated user."""
@@ -253,8 +273,17 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
This endpoint validates that the key exists in LiteLLM before returning it.
If validation fails, it automatically generates a new key to ensure users
always receive a working key.
Returns 402 Payment Required if BYOR export is not enabled for the user's org.
"""
try:
# Check if BYOR export is enabled for the user's org
if not await OrgService.check_byor_export_enabled(user_id):
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
)
# Check if the BYOR key exists in the database
byor_key = await get_byor_key_from_db(user_id)
if byor_key:
@@ -310,10 +339,20 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
@api_router.post('/llm/byor/refresh', response_model=LlmApiKeyResponse)
async def refresh_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user."""
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user.
Returns 402 Payment Required if BYOR export is not enabled for the user's org.
"""
logger.info('Starting BYOR LLM API key refresh', extra={'user_id': user_id})
try:
# Check if BYOR export is enabled for the user's org
if not await OrgService.check_byor_export_enabled(user_id):
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
)
# Get the existing BYOR key from the database
existing_byor_key = await get_byor_key_from_db(user_id)

View File

@@ -17,6 +17,7 @@ from starlette.datastructures import URL
from storage.billing_session import BillingSession
from storage.database import session_maker
from storage.lite_llm_manager import LiteLlmManager
from storage.org import Org
from storage.subscription_access import SubscriptionAccess
from storage.user_store import UserStore
@@ -259,6 +260,12 @@ async def success_callback(session_id: str, request: Request):
str(user.current_org_id), new_max_budget
)
# Enable BYOR export for the org now that they've purchased credits
# Update within the same session to avoid nested session issues
org = session.query(Org).filter(Org.id == user.current_org_id).first()
if org:
org.byor_export_enabled = True
# Store transaction status
billing_session.status = 'completed'
billing_session.price = add_credits

View File

@@ -1,7 +1,9 @@
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field, StringConstraints
from pydantic import BaseModel, EmailStr, Field, SecretStr, StringConstraints
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
class OrgCreationError(Exception):
@@ -43,6 +45,16 @@ class OrgAuthorizationError(OrgDeletionError):
super().__init__(message)
class OrphanedUserError(OrgDeletionError):
"""Raised when deleting an org would leave users without any organization."""
def __init__(self, user_ids: list[str]):
self.user_ids = user_ids
super().__init__(
f'Cannot delete organization: {len(user_ids)} user(s) would have no remaining organization'
)
class OrgNotFoundError(Exception):
"""Raised when organization is not found or user doesn't have access."""
@@ -51,6 +63,61 @@ class OrgNotFoundError(Exception):
super().__init__(f'Organization with id "{org_id}" not found')
class OrgMemberNotFoundError(Exception):
"""Raised when a member is not found in an organization."""
def __init__(self, org_id: str, user_id: str):
self.org_id = org_id
self.user_id = user_id
super().__init__(f'Member "{user_id}" not found in organization "{org_id}"')
class RoleNotFoundError(Exception):
"""Raised when a role is not found."""
def __init__(self, role_id: int):
self.role_id = role_id
super().__init__(f'Role with id "{role_id}" not found')
class InvalidRoleError(Exception):
"""Raised when an invalid role name is specified."""
def __init__(self, role_name: str):
self.role_name = role_name
super().__init__(f'Invalid role: "{role_name}"')
class InsufficientPermissionError(Exception):
"""Raised when user lacks permission to perform an operation."""
def __init__(self, message: str = 'Insufficient permission'):
super().__init__(message)
class CannotModifySelfError(Exception):
"""Raised when user attempts to modify their own membership."""
def __init__(self, action: str = 'modify'):
self.action = action
super().__init__(f'Cannot {action} your own membership')
class LastOwnerError(Exception):
"""Raised when attempting to remove or demote the last owner."""
def __init__(self, action: str = 'remove'):
self.action = action
super().__init__(f'Cannot {action} the last owner of an organization')
class MemberUpdateError(Exception):
"""Raised when member update operation fails."""
def __init__(self, message: str = 'Failed to update member'):
super().__init__(message)
class OrgCreate(BaseModel):
"""Request model for creating a new organization."""
@@ -91,14 +158,18 @@ class OrgResponse(BaseModel):
enable_solvability_analysis: bool | None = None
v1_enabled: bool | None = None
credits: float | None = None
is_personal: bool = False
@classmethod
def from_org(cls, org: Org, credits: float | None = None) -> 'OrgResponse':
def from_org(
cls, org: Org, credits: float | None = None, user_id: str | None = None
) -> 'OrgResponse':
"""Create an OrgResponse from an Org entity.
Args:
org: The organization entity to convert
credits: Optional credits value (defaults to None)
user_id: Optional user ID to determine if org is personal (defaults to None)
Returns:
OrgResponse: The response model instance
@@ -134,6 +205,7 @@ class OrgResponse(BaseModel):
enable_solvability_analysis=org.enable_solvability_analysis,
v1_enabled=org.v1_enabled,
credits=credits,
is_personal=str(org.id) == user_id if user_id else False,
)
@@ -148,6 +220,10 @@ class OrgUpdate(BaseModel):
"""Request model for updating an organization."""
# Basic organization information (any authenticated user can update)
name: Annotated[
str | None,
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
] = None
contact_name: str | None = None
contact_email: EmailStr | None = None
conversation_expiration: int | None = None
@@ -173,3 +249,79 @@ class OrgUpdate(BaseModel):
confirmation_mode: bool | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
class OrgMemberResponse(BaseModel):
"""Response model for a single organization member."""
user_id: str
email: str | None
role_id: int
role_name: str
role_rank: int
status: str | None
class OrgMemberPage(BaseModel):
"""Paginated response for organization members."""
items: list[OrgMemberResponse]
next_page_id: str | None = None
class OrgMemberUpdate(BaseModel):
"""Request model for updating an organization member."""
role: str | None = None # Role name: 'owner', 'admin', or 'member'
class MeResponse(BaseModel):
"""Response model for the current user's membership in an organization."""
org_id: str
user_id: str
email: str
role: str
llm_api_key: str
max_iterations: int | None = None
llm_model: str | None = None
llm_api_key_for_byor: str | None = None
llm_base_url: str | None = None
status: str | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return ''
raw = secret.get_secret_value()
if not raw:
return ''
if len(raw) <= 4:
return '****'
return '****' + raw[-4:]
@classmethod
def from_org_member(cls, member: OrgMember, role: Role, email: str) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email.
Args:
member: The OrgMember entity
role: The Role entity (provides role name)
email: The user's email address
Returns:
MeResponse with masked API keys
"""
return cls(
org_id=str(member.org_id),
user_id=str(member.user_id),
email=email,
role=role.name,
llm_api_key=cls._mask_key(member.llm_api_key),
max_iterations=member.max_iterations,
llm_model=member.llm_model,
llm_api_key_for_byor=cls._mask_key(member.llm_api_key_for_byor) or None,
llm_base_url=member.llm_base_url,
status=member.status,
)

View File

@@ -2,18 +2,36 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from server.auth.authorization import (
require_org_admin,
require_org_owner,
require_org_user,
)
from server.email_validation import get_admin_user_id
from server.routes.org_models import (
CannotModifySelfError,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
LiteLLMIntegrationError,
MemberUpdateError,
MeResponse,
OrgAuthorizationError,
OrgCreate,
OrgDatabaseError,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
OrgMemberUpdate,
OrgNameExistsError,
OrgNotFoundError,
OrgPage,
OrgResponse,
OrgUpdate,
OrphanedUserError,
RoleNotFoundError,
)
from server.services.org_member_service import OrgMemberService
from storage.org_service import OrgService
from openhands.core.logger import openhands_logger as logger
@@ -69,7 +87,9 @@ async def list_user_orgs(
)
# Convert Org entities to OrgResponse objects
org_responses = [OrgResponse.from_org(org, credits=None) for org in orgs]
org_responses = [
OrgResponse.from_org(org, credits=None, user_id=user_id) for org in orgs
]
logger.info(
'Successfully retrieved organizations',
@@ -136,7 +156,7 @@ async def create_org(
# Retrieve credits from LiteLLM
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNameExistsError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -174,23 +194,26 @@ async def create_org(
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
async def get_org(
org_id: UUID,
user_id: str = Depends(get_user_id),
user_id: str = Depends(require_org_user),
) -> OrgResponse:
"""Get organization details by ID.
This endpoint allows authenticated users who are members of an organization
to retrieve its details. Only members of the organization can access this endpoint.
Requires user, admin, or owner role.
Args:
org_id: Organization ID (UUID)
user_id: Authenticated user ID (injected by dependency)
user_id: Authenticated user ID (injected by dependency, requires org membership)
Returns:
OrgResponse: The organization details
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user is not a member of the organization
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
HTTPException: 404 if organization not found or user is not a member
HTTPException: 404 if organization not found
HTTPException: 500 if retrieval fails
"""
logger.info(
@@ -211,7 +234,7 @@ async def get_org(
# Retrieve credits from LiteLLM
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -228,26 +251,87 @@ async def get_org(
)
@org_router.get('/{org_id}/me', response_model=MeResponse)
async def get_me(
org_id: UUID,
user_id: str = Depends(get_user_id),
) -> MeResponse:
"""Get the current user's membership record for an organization.
Returns the authenticated user's role, status, email, and LLM override
fields (with masked API keys) within the specified organization.
Args:
org_id: Organization ID (UUID)
user_id: Authenticated user ID (injected by dependency)
Returns:
MeResponse: The user's membership data
Raises:
HTTPException: 404 if user is not a member or org doesn't exist
HTTPException: 500 if retrieval fails
"""
logger.info(
'Retrieving current member details',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
try:
user_uuid = UUID(user_id)
return OrgMemberService.get_me(org_id, user_uuid)
except OrgMemberNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Organization with id "{org_id}" not found',
)
except RoleNotFoundError as e:
logger.exception(
'Role not found for org member',
extra={
'user_id': user_id,
'org_id': str(org_id),
'role_id': e.role_id,
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
except Exception as e:
logger.exception(
'Unexpected error retrieving member details',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.delete('/{org_id}', status_code=status.HTTP_200_OK)
async def delete_org(
org_id: UUID,
user_id: str = Depends(get_admin_user_id),
user_id: str = Depends(require_org_owner),
) -> dict:
"""Delete an organization.
This endpoint allows authenticated organization owners to delete their organization.
All associated data including organization members, conversations, billing data,
and external LiteLLM team resources will be permanently removed.
Requires owner role.
Args:
org_id: Organization ID to delete
user_id: Authenticated user ID (injected by dependency)
user_id: Authenticated user ID (injected by dependency, requires owner role)
Returns:
dict: Confirmation message with deleted organization details
Raises:
HTTPException: 403 if user is not the organization owner
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user is not an owner of the organization
HTTPException: 404 if organization not found
HTTPException: 500 if deletion fails
"""
@@ -303,6 +387,19 @@ async def delete_org(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except OrphanedUserError as e:
logger.warning(
'Cannot delete organization: users would be orphaned',
extra={
'user_id': user_id,
'org_id': str(org_id),
'orphaned_users': e.user_ids,
},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database error during organization deletion',
@@ -327,24 +424,24 @@ async def delete_org(
async def update_org(
org_id: UUID,
update_data: OrgUpdate,
user_id: str = Depends(get_user_id),
user_id: str = Depends(require_org_admin),
) -> OrgResponse:
"""Update an existing organization.
This endpoint allows authenticated users to update organization settings.
LLM-related settings require admin or owner role in the organization.
This endpoint allows authenticated admins and owners to update organization settings.
Requires admin or owner role in the organization.
Args:
org_id: Organization ID to update (UUID validated by FastAPI)
update_data: Organization update data
user_id: Authenticated user ID (injected by dependency)
user_id: Authenticated user ID (injected by dependency, requires admin role)
Returns:
OrgResponse: The updated organization details
Raises:
HTTPException: 400 if org_id is invalid UUID format (handled by FastAPI)
HTTPException: 403 if user lacks permission for LLM settings
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user is not an admin or owner of the organization
HTTPException: 404 if organization not found
HTTPException: 422 if validation errors occur (handled by FastAPI)
HTTPException: 500 if update fails
@@ -368,7 +465,7 @@ async def update_org(
# Retrieve credits from LiteLLM (following same pattern as create endpoint)
credits = await OrgService.get_org_credits(user_id, updated_org.id)
return OrgResponse.from_org(updated_org, credits=credits)
return OrgResponse.from_org(updated_org, credits=credits, user_id=user_id)
except ValueError as e:
# Organization not found
@@ -376,6 +473,11 @@ async def update_org(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgNameExistsError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
except PermissionError as e:
# User lacks permission for LLM settings
raise HTTPException(
@@ -400,3 +502,294 @@ async def update_org(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.get('/{org_id}/members')
async def get_org_members(
org_id: str,
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(
title='The max number of results in the page',
gt=0,
lte=100,
),
] = 100,
current_user_id: str = Depends(get_user_id),
) -> OrgMemberPage:
"""Get all members of an organization with cursor-based pagination."""
try:
success, error_code, data = await OrgMemberService.get_org_members(
org_id=UUID(org_id),
current_user_id=UUID(current_user_id),
page_id=page_id,
limit=limit,
)
if not success:
error_map = {
'not_a_member': (
status.HTTP_403_FORBIDDEN,
'You are not a member of this organization',
),
'invalid_page_id': (
status.HTTP_400_BAD_REQUEST,
'Invalid page_id format',
),
}
status_code, detail = error_map.get(
error_code, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
)
raise HTTPException(status_code=status_code, detail=detail)
if data is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve members',
)
return data
except HTTPException:
raise
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization ID format',
)
except Exception:
logger.exception('Error retrieving organization members')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve members',
)
@org_router.delete('/{org_id}/members/{user_id}')
async def remove_org_member(
org_id: str,
user_id: str,
current_user_id: str = Depends(get_user_id),
):
"""Remove a member from an organization.
Only owners and admins can remove members:
- Owners can remove admins and regular users
- Admins can only remove regular users
Users cannot remove themselves. The last owner cannot be removed.
"""
try:
success, error = await OrgMemberService.remove_org_member(
org_id=UUID(org_id),
target_user_id=UUID(user_id),
current_user_id=UUID(current_user_id),
)
if not success:
error_map = {
'not_a_member': (
status.HTTP_403_FORBIDDEN,
'You are not a member of this organization',
),
'cannot_remove_self': (
status.HTTP_403_FORBIDDEN,
'Cannot remove yourself from an organization',
),
'member_not_found': (
status.HTTP_404_NOT_FOUND,
'Member not found in this organization',
),
'insufficient_permission': (
status.HTTP_403_FORBIDDEN,
'You do not have permission to remove this member',
),
'cannot_remove_last_owner': (
status.HTTP_400_BAD_REQUEST,
'Cannot remove the last owner of an organization',
),
'removal_failed': (
status.HTTP_500_INTERNAL_SERVER_ERROR,
'Failed to remove member',
),
}
status_code, detail = error_map.get(
error, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
)
raise HTTPException(status_code=status_code, detail=detail)
return {'message': 'Member removed successfully'}
except HTTPException:
raise
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization or user ID format',
)
except Exception:
logger.exception('Error removing organization member')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to remove member',
)
@org_router.post(
'/{org_id}/switch', response_model=OrgResponse, status_code=status.HTTP_200_OK
)
async def switch_org(
org_id: UUID,
user_id: str = Depends(get_user_id),
) -> OrgResponse:
"""Switch to a different organization.
This endpoint allows authenticated users to switch their current active
organization. The user must be a member of the target organization.
Args:
org_id: Organization ID to switch to (UUID)
user_id: Authenticated user ID (injected by dependency)
Returns:
OrgResponse: The organization details that was switched to
Raises:
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
HTTPException: 403 if user is not a member of the organization
HTTPException: 404 if organization not found
HTTPException: 500 if switch fails
"""
logger.info(
'Switching organization',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
try:
# Use service layer to switch organization with membership validation
org = await OrgService.switch_org(
user_id=user_id,
org_id=org_id,
)
# Retrieve credits from LiteLLM for the new current org
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgAuthorizationError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database operation failed during organization switch',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to switch organization',
)
except Exception as e:
logger.exception(
'Unexpected error switching organization',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.patch('/{org_id}/members/{user_id}', response_model=OrgMemberResponse)
async def update_org_member(
org_id: str,
user_id: str,
update_data: OrgMemberUpdate,
current_user_id: str = Depends(get_user_id),
) -> OrgMemberResponse:
"""Update a member's role in an organization.
Permission rules:
- Admins can change roles of regular members to Admin or Member
- Admins cannot modify other Admins or Owners
- Owners can change roles of Admins and Members to any role (Owner, Admin, Member)
- Owners cannot modify other Owners
Members cannot modify their own role. The last owner cannot be demoted.
"""
try:
return await OrgMemberService.update_org_member(
org_id=UUID(org_id),
target_user_id=UUID(user_id),
current_user_id=UUID(current_user_id),
update_data=update_data,
)
except OrgMemberNotFoundError as e:
# Distinguish between requester not being a member vs target not found
if str(current_user_id) in str(e):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You are not a member of this organization',
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Member not found in this organization',
)
except CannotModifySelfError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot modify your own role',
)
except RoleNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Role configuration error',
)
except InvalidRoleError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid role specified',
)
except InsufficientPermissionError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You do not have permission to modify this member',
)
except LastOwnerError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot demote the last owner of an organization',
)
except MemberUpdateError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization or user ID format',
)
except Exception:
logger.exception('Error updating organization member')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)

View File

@@ -1139,6 +1139,71 @@ class SaasNestedConversationManager(ConversationManager):
}
update_conversation_metadata(conversation_id, metadata_content)
async def list_files(self, sid: str, path: str | None = None) -> list[str]:
"""List files in the workspace for a conversation.
Delegates to the nested container's list-files endpoint.
Args:
sid: The session/conversation ID.
path: Optional path to list files from. If None, lists from workspace root.
Returns:
A list of file paths.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_list_files_from_nested(
sid, nested_url, session_api_key, path
)
async def select_file(self, sid: str, file: str) -> tuple[str | None, str | None]:
"""Read a file from the workspace via nested container.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_select_file_from_nested(
sid, nested_url, session_api_key, file
)
async def upload_files(
self, sid: str, files: list[tuple[str, bytes]]
) -> tuple[list[str], list[dict[str, str]]]:
"""Upload files to the workspace via nested container.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_upload_files_to_nested(
sid, nested_url, session_api_key, files
)
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
last_updated_at = conversation.last_updated_at

View File

@@ -0,0 +1,342 @@
"""Service for managing organization members."""
from uuid import UUID
from server.constants import ROLE_ADMIN, ROLE_MEMBER, ROLE_OWNER
from server.routes.org_models import (
CannotModifySelfError,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
MemberUpdateError,
MeResponse,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
OrgMemberUpdate,
RoleNotFoundError,
)
from storage.org_member_store import OrgMemberStore
from storage.role_store import RoleStore
from storage.user_store import UserStore
from openhands.utils.async_utils import call_sync_from_async
class OrgMemberService:
"""Service for organization member operations."""
@staticmethod
def get_me(org_id: UUID, user_id: UUID) -> MeResponse:
"""Get the current user's membership record for an organization.
Retrieves the authenticated user's role, status, email, and LLM override
fields (with masked API keys) within the specified organization.
Args:
org_id: Organization ID (UUID)
user_id: User ID (UUID)
Returns:
MeResponse: The user's membership data with masked API keys
Raises:
OrgMemberNotFoundError: If user is not a member of the organization
RoleNotFoundError: If the role associated with the member is not found
"""
# Look up the user's membership in this org
org_member = OrgMemberStore.get_org_member(org_id, user_id)
if org_member is None:
raise OrgMemberNotFoundError(str(org_id), str(user_id))
# Resolve role name from role_id
role = RoleStore.get_role_by_id(org_member.role_id)
if role is None:
raise RoleNotFoundError(org_member.role_id)
# Get user email
user = UserStore.get_user_by_id(str(user_id))
email = user.email if user and user.email else ''
return MeResponse.from_org_member(org_member, role, email)
@staticmethod
async def get_org_members(
org_id: UUID,
current_user_id: UUID,
page_id: str | None = None,
limit: int = 100,
) -> tuple[bool, str | None, OrgMemberPage | None]:
"""Get organization members with authorization check.
Returns:
Tuple of (success, error_code, data). If success is True, error_code is None.
"""
# Verify current user is a member of the organization
requester_membership = OrgMemberStore.get_org_member(org_id, current_user_id)
if not requester_membership:
return False, 'not_a_member', None
# Parse page_id to get offset (page_id is offset encoded as string)
offset = 0
if page_id is not None:
try:
offset = int(page_id)
if offset < 0:
return False, 'invalid_page_id', None
except ValueError:
return False, 'invalid_page_id', None
# Call store to get paginated members
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=offset, limit=limit
)
# Transform data to response format
items = []
for member in members:
# Access user and role relationships (eagerly loaded)
user = member.user
role = member.role
items.append(
OrgMemberResponse(
user_id=str(member.user_id),
email=user.email if user else None,
role_id=member.role_id,
role_name=role.name if role else '',
role_rank=role.rank if role else 0,
status=member.status,
)
)
# Calculate next_page_id
next_page_id = None
if has_more:
next_page_id = str(offset + limit)
return True, None, OrgMemberPage(items=items, next_page_id=next_page_id)
@staticmethod
async def remove_org_member(
org_id: UUID,
target_user_id: UUID,
current_user_id: UUID,
) -> tuple[bool, str | None]:
"""Remove a member from an organization.
Returns:
Tuple of (success, error_message). If success is True, error_message is None.
"""
def _remove_member():
# Get current user's membership in the org
requester_membership = OrgMemberStore.get_org_member(
org_id, current_user_id
)
if not requester_membership:
return False, 'not_a_member'
# Check if trying to remove self
if str(current_user_id) == str(target_user_id):
return False, 'cannot_remove_self'
# Get target user's membership
target_membership = OrgMemberStore.get_org_member(org_id, target_user_id)
if not target_membership:
return False, 'member_not_found'
requester_role = RoleStore.get_role_by_id(requester_membership.role_id)
target_role = RoleStore.get_role_by_id(target_membership.role_id)
if not requester_role or not target_role:
return False, 'role_not_found'
# Check permission based on roles
if not OrgMemberService._can_remove_member(
requester_role.name, target_role.name
):
return False, 'insufficient_permission'
# Check if removing the last owner
if target_role.name == ROLE_OWNER:
if OrgMemberService._is_last_owner(org_id, target_user_id):
return False, 'cannot_remove_last_owner'
# Perform the removal
success = OrgMemberStore.remove_user_from_org(org_id, target_user_id)
if not success:
return False, 'removal_failed'
return True, None
return await call_sync_from_async(_remove_member)
@staticmethod
async def update_org_member(
org_id: UUID,
target_user_id: UUID,
current_user_id: UUID,
update_data: OrgMemberUpdate,
) -> OrgMemberResponse:
"""Update a member's role in an organization.
Permission rules:
- Admins can change roles of users (rank > ADMIN_RANK) to Admin or User
- Admins cannot modify other Admins or Owners
- Owners can change roles of non-owners (rank > OWNER_RANK) to any role
- Owners cannot modify other Owners
Args:
org_id: Organization ID
target_user_id: User ID of the member to update
current_user_id: User ID of the requester
update_data: Update data containing fields to modify
Returns:
OrgMemberResponse: The updated member data
Raises:
OrgMemberNotFoundError: If requester or target is not a member
CannotModifySelfError: If trying to modify self
RoleNotFoundError: If role configuration is invalid
InvalidRoleError: If new_role_name is not a valid role
InsufficientPermissionError: If requester lacks permission
LastOwnerError: If trying to demote the last owner
MemberUpdateError: If update operation fails
"""
new_role_name = update_data.role
def _update_member():
# Get current user's membership in the org
requester_membership = OrgMemberStore.get_org_member(
org_id, current_user_id
)
if not requester_membership:
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
# Check if trying to modify self
if str(current_user_id) == str(target_user_id):
raise CannotModifySelfError('modify')
# Get target user's membership
target_membership = OrgMemberStore.get_org_member(org_id, target_user_id)
if not target_membership:
raise OrgMemberNotFoundError(str(org_id), str(target_user_id))
# Get roles
requester_role = RoleStore.get_role_by_id(requester_membership.role_id)
target_role = RoleStore.get_role_by_id(target_membership.role_id)
if not requester_role:
raise RoleNotFoundError(requester_membership.role_id)
if not target_role:
raise RoleNotFoundError(target_membership.role_id)
# If no role change requested, return current state
if new_role_name is None:
user = UserStore.get_user_by_id(str(target_user_id))
return OrgMemberResponse(
user_id=str(target_membership.user_id),
email=user.email if user else None,
role_id=target_membership.role_id,
role_name=target_role.name,
role_rank=target_role.rank,
status=target_membership.status,
)
# Validate new role exists
new_role = RoleStore.get_role_by_name(new_role_name.lower())
if not new_role:
raise InvalidRoleError(new_role_name)
# Check permission to modify target
if not OrgMemberService._can_update_member_role(
requester_role.name, target_role.name, new_role.name
):
raise InsufficientPermissionError(
'You do not have permission to modify this member'
)
# Check if demoting the last owner
if (
target_role.name == ROLE_OWNER
and new_role.name != ROLE_OWNER
and OrgMemberService._is_last_owner(org_id, target_user_id)
):
raise LastOwnerError('demote')
# Perform the update
updated_member = OrgMemberStore.update_user_role_in_org(
org_id, target_user_id, new_role.id
)
if not updated_member:
raise MemberUpdateError('Failed to update member')
# Get user email for response
user = UserStore.get_user_by_id(str(target_user_id))
return OrgMemberResponse(
user_id=str(updated_member.user_id),
email=user.email if user else None,
role_id=updated_member.role_id,
role_name=new_role.name,
role_rank=new_role.rank,
status=updated_member.status,
)
return await call_sync_from_async(_update_member)
@staticmethod
def _can_update_member_role(
requester_role_name: str, target_role_name: str, new_role_name: str
) -> bool:
"""Check if requester can change target's role to new_role.
Permission rules:
- Owners can modify admins and users, can set any role
- Owners cannot modify other owners
- Admins can only modify users
- Admins can only set admin or user roles (not owner)
"""
is_requester_owner = requester_role_name == ROLE_OWNER
is_requester_admin = requester_role_name == ROLE_ADMIN
is_target_owner = target_role_name == ROLE_OWNER
is_target_admin = target_role_name == ROLE_ADMIN
is_new_role_owner = new_role_name == ROLE_OWNER
if is_requester_owner:
# Owners cannot modify other owners
if is_target_owner:
return False
# Owners can set any role (owner, admin, user)
return True
elif is_requester_admin:
# Admins cannot modify owners or other admins
if is_target_owner or is_target_admin:
return False
# Admins can only set admin or user roles (not owner)
return not is_new_role_owner
return False
@staticmethod
def _can_remove_member(requester_role_name: str, target_role_name: str) -> bool:
"""Check if requester can remove target based on roles."""
if requester_role_name == ROLE_OWNER:
return True
elif requester_role_name == ROLE_ADMIN:
# Admins can only remove members (not owners or other admins)
return target_role_name == ROLE_MEMBER
return False
@staticmethod
def _is_last_owner(org_id: UUID, user_id: UUID) -> bool:
"""Check if user is the last owner of the organization."""
members = OrgMemberStore.get_org_members(org_id)
owners = []
for m in members:
# Use role_id (column) instead of role (relationship) to avoid DetachedInstanceError
role = RoleStore.get_role_by_id(m.role_id)
if role and role.name == ROLE_OWNER:
owners.append(m)
return len(owners) == 1 and str(owners[0].user_id) == str(user_id)

View File

@@ -233,7 +233,13 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
result = result_set.first()
if result:
stored_metadata, saas_metadata = result
return self._to_info_with_user_id(stored_metadata, saas_metadata)
# Fetch sub-conversation IDs
sub_conversation_ids = await self.get_sub_conversation_ids(conversation_id)
return self._to_info_with_user_id(
stored_metadata,
saas_metadata,
sub_conversation_ids=sub_conversation_ids,
)
return None
async def batch_get_app_conversation_info(
@@ -262,8 +268,16 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
for conversation_id in conversation_id_strs:
if conversation_id in info_by_id:
stored_metadata, saas_metadata = info_by_id[conversation_id]
# Fetch sub-conversation IDs for each conversation
sub_conversation_ids = await self.get_sub_conversation_ids(
UUID(conversation_id)
)
results.append(
self._to_info_with_user_id(stored_metadata, saas_metadata)
self._to_info_with_user_id(
stored_metadata,
saas_metadata,
sub_conversation_ids=sub_conversation_ids,
)
)
else:
results.append(None)
@@ -316,10 +330,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
self,
stored: StoredConversationMetadata,
saas_metadata: StoredConversationMetadataSaas,
sub_conversation_ids: list[UUID] | None = None,
) -> AppConversationInfo:
"""Convert stored metadata to AppConversationInfo with user_id from SAAS metadata."""
# Use the base _to_info method to get the basic info
info = self._to_info(stored)
info = self._to_info(stored, sub_conversation_ids=sub_conversation_ids)
# Override the created_by_user_id with the user_id from SAAS metadata
info.created_by_user_id = (

View File

@@ -46,6 +46,7 @@ class Org(Base): # type: ignore
v1_enabled = Column(Boolean, nullable=True)
conversation_expiration = Column(Integer, nullable=True)
condenser_max_size = Column(Integer, nullable=True)
byor_export_enabled = Column(Boolean, nullable=False, default=False)
# Relationships
org_members = relationship('OrgMember', back_populates='org')

View File

@@ -6,6 +6,7 @@ from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from storage.database import a_session_maker, session_maker
from storage.org_member import OrgMember
from storage.user_settings import UserSettings
@@ -135,3 +136,36 @@ class OrgMemberStore:
if (normalized := c.name.lstrip('_')) and hasattr(user_settings, normalized)
}
return kwargs
@staticmethod
async def get_org_members_paginated(
org_id: UUID,
offset: int = 0,
limit: int = 100,
) -> tuple[list[OrgMember], bool]:
"""Get paginated list of organization members with user and role info.
Returns:
Tuple of (members_list, has_more) where has_more indicates if there are more results.
"""
async with a_session_maker() as session:
# Query for limit + 1 items to determine if there are more results
# Order by user_id for consistent pagination
query = (
select(OrgMember)
.options(joinedload(OrgMember.user), joinedload(OrgMember.role))
.filter(OrgMember.org_id == org_id)
.order_by(OrgMember.user_id)
.offset(offset)
.limit(limit + 1)
)
result = await session.execute(query)
members = list(result.scalars().all())
# Check if there are more results
has_more = len(members) > limit
if has_more:
# Remove the extra item
members = members[:limit]
return members, has_more

View File

@@ -521,6 +521,7 @@ class OrgService:
Raises:
ValueError: If organization not found
PermissionError: If user is not a member, or lacks admin/owner role for LLM settings
OrgNameExistsError: If new name already exists for another organization
OrgDatabaseError: If database update fails
"""
logger.info(
@@ -550,6 +551,24 @@ class OrgService:
'User must be a member of the organization to update it'
)
# Check if name is being updated and validate uniqueness
if update_data.name is not None:
# Check if new name conflicts with another org
existing_org_with_name = OrgStore.get_org_by_name(update_data.name)
if (
existing_org_with_name is not None
and existing_org_with_name.id != org_id
):
logger.warning(
'Attempted to update organization with duplicate name',
extra={
'user_id': user_id,
'org_id': str(org_id),
'attempted_name': update_data.name,
},
)
raise OrgNameExistsError(update_data.name)
# Check if update contains any LLM settings
llm_fields_being_updated = OrgService._has_llm_settings_updates(update_data)
if llm_fields_being_updated:
@@ -842,3 +861,94 @@ class OrgService:
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise OrgDatabaseError(f'Failed to delete organization: {str(e)}')
@staticmethod
async def check_byor_export_enabled(user_id: str) -> bool:
"""Check if BYOR export is enabled for the user's current org.
Returns True if the user's current org has byor_export_enabled set to True.
Returns False if the user is not found, has no current org, or the flag is False.
Args:
user_id: User ID to check
Returns:
bool: True if BYOR export is enabled, False otherwise
"""
user = await UserStore.get_user_by_id_async(user_id)
if not user or not user.current_org_id:
return False
org = OrgStore.get_org_by_id(user.current_org_id)
if not org:
return False
return org.byor_export_enabled
@staticmethod
async def switch_org(user_id: str, org_id: UUID) -> Org:
"""
Switch user's current organization to the specified organization.
This method:
1. Validates that the organization exists
2. Validates that the user is a member of the organization
3. Updates the user's current_org_id
Args:
user_id: User ID (string that will be converted to UUID)
org_id: Organization ID to switch to
Returns:
Org: The organization that was switched to
Raises:
OrgNotFoundError: If organization doesn't exist
OrgAuthorizationError: If user is not a member of the organization
OrgDatabaseError: If database update fails
"""
logger.info(
'Switching user organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
# Step 1: Check if organization exists
org = OrgStore.get_org_by_id(org_id)
if not org:
raise OrgNotFoundError(str(org_id))
# Step 2: Validate user is a member of the organization
if not OrgService.is_org_member(user_id, org_id):
logger.warning(
'User attempted to switch to organization they are not a member of',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise OrgAuthorizationError(
'User must be a member of the organization to switch to it'
)
# Step 3: Update user's current_org_id
try:
updated_user = UserStore.update_current_org(user_id, org_id)
if not updated_user:
raise OrgDatabaseError('User not found')
logger.info(
'Successfully switched user organization',
extra={
'user_id': user_id,
'org_id': str(org_id),
'org_name': org.name,
},
)
return org
except OrgDatabaseError:
raise
except Exception as e:
logger.error(
'Failed to switch user organization',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise OrgDatabaseError(f'Failed to switch organization: {str(e)}')

View File

@@ -10,6 +10,7 @@ from server.constants import (
ORG_SETTINGS_VERSION,
get_default_litellm_model,
)
from server.routes.org_models import OrphanedUserError
from sqlalchemy import text
from sqlalchemy.orm import joinedload
from storage.database import session_maker
@@ -320,17 +321,41 @@ class OrgStore:
{'org_id': str(org_id)},
)
# 3. Delete organization memberships
# 3. Handle users with this as current_org_id BEFORE deleting memberships
# Single query to find orphaned users (those with no alternative org)
orphaned_users = session.execute(
text("""
SELECT u.id
FROM "user" u
WHERE u.current_org_id = :org_id
AND NOT EXISTS (
SELECT 1 FROM org_member om
WHERE om.user_id = u.id AND om.org_id != :org_id
)
"""),
{'org_id': str(org_id)},
).fetchall()
if orphaned_users:
raise OrphanedUserError([str(row[0]) for row in orphaned_users])
# Batch update: reassign current_org_id to an alternative org for all affected users
session.execute(
text('DELETE FROM org_member WHERE org_id = :org_id'),
text("""
UPDATE "user" u
SET current_org_id = (
SELECT om.org_id FROM org_member om
WHERE om.user_id = u.id AND om.org_id != :org_id
LIMIT 1
)
WHERE u.current_org_id = :org_id
"""),
{'org_id': str(org_id)},
)
# 4. Handle users with this as current_org_id
# 4. Delete organization memberships (now safe)
session.execute(
text(
'UPDATE "user" SET current_org_id = NULL WHERE current_org_id = :org_id'
),
text('DELETE FROM org_member WHERE org_id = :org_id'),
{'org_id': str(org_id)},
)

View File

@@ -24,6 +24,7 @@ from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.settings import Settings
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.llm import is_openhands_model
@dataclass
@@ -106,13 +107,13 @@ class SaasSettingsStore(SettingsStore):
},
}
kwargs['llm_api_key'] = org_member.llm_api_key
if org_member.max_iterations is not None:
if org_member.max_iterations:
kwargs['max_iterations'] = org_member.max_iterations
if org_member.llm_model is not None:
if org_member.llm_model:
kwargs['llm_model'] = org_member.llm_model
if org_member.llm_api_key_for_byor is not None:
if org_member.llm_api_key_for_byor:
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
if org_member.llm_base_url is not None:
if org_member.llm_base_url:
kwargs['llm_base_url'] = org_member.llm_base_url
if org.v1_enabled is None:
kwargs['v1_enabled'] = True
@@ -161,9 +162,10 @@ class SaasSettingsStore(SettingsStore):
return None
# Check if we need to generate an LLM key.
is_openhands_provider = self._is_openhands_provider(item)
if is_openhands_provider or item.llm_base_url == LITE_LLM_API_URL:
await self._ensure_api_key(item, str(org_id), is_openhands_provider)
if item.llm_base_url == LITE_LLM_API_URL:
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
)
kwargs = item.model_dump(context={'expose_secrets': True})
for model in (user, org, org_member):
@@ -228,10 +230,6 @@ class SaasSettingsStore(SettingsStore):
fernet_key = b64encode(hashlib.sha256(jwt_secret.encode()).digest())
return Fernet(fernet_key)
def _is_openhands_provider(self, item: Settings) -> bool:
"""Check if the settings use the OpenHands provider."""
return bool(item.llm_model and item.llm_model.startswith('openhands/'))
async def _ensure_api_key(
self, item: Settings, org_id: str, openhands_type: bool = False
) -> None:

View File

@@ -5,6 +5,7 @@ Store class for managing users.
import asyncio
import uuid
from typing import Optional
from uuid import UUID
from server.auth.token_manager import TokenManager
from server.constants import (
@@ -172,6 +173,19 @@ class UserStore:
)
decrypted_user_settings = UserSettings(**kwargs)
with session_maker() as session:
# Check if user has completed billing sessions to enable BYOR export
from storage.billing_session import BillingSession
has_completed_billing = (
session.query(BillingSession)
.filter(
BillingSession.user_id == user_id,
BillingSession.status == 'completed',
)
.first()
is not None
)
# create personal org
org = Org(
id=uuid.UUID(user_id),
@@ -180,6 +194,7 @@ class UserStore:
contact_name=resolve_display_name(user_info)
or user_info.get('username', ''),
contact_email=user_info['email'],
byor_export_enabled=has_completed_billing,
)
session.add(org)
@@ -759,6 +774,32 @@ class UserStore:
with session_maker() as session:
return session.query(User).all()
@staticmethod
def update_current_org(user_id: str, org_id: UUID) -> Optional[User]:
"""Update the user's current organization.
Args:
user_id: The user's ID (Keycloak user ID)
org_id: The organization ID to set as current
Returns:
User: The updated user object, or None if user not found
"""
with session_maker() as session:
user = (
session.query(User)
.filter(User.id == uuid.UUID(user_id))
.with_for_update()
.first()
)
if not user:
return None
user.current_org_id = org_id
session.commit()
session.refresh(user)
return user
@staticmethod
async def backfill_contact_name(user_id: str, user_info: dict) -> None:
"""Update contact_name on the personal org if it still has a username-style value.

View File

@@ -26,6 +26,7 @@ Optional environment variables:
"""
import os
import re
import sys
import time
from typing import Any, Dict, List, Optional
@@ -90,6 +91,31 @@ class ResendAPIError(ResendSyncError):
pass
# Email validation regex pattern - matches standard email format
# This pattern is intentionally strict to avoid Resend API validation errors
# It rejects special characters like ! that some email providers technically allow
# but Resend's API does not accept
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
def is_valid_email(email: str) -> bool:
"""Validate an email address format.
This uses a regex pattern that matches most valid email addresses
while rejecting addresses with special characters that Resend's API
does not accept (e.g., exclamation marks).
Args:
email: The email address to validate.
Returns:
True if the email is valid, False otherwise.
"""
if not email:
return False
return bool(EMAIL_REGEX.match(email))
def get_keycloak_users(offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""Get users from Keycloak using the admin client.
@@ -336,6 +362,7 @@ def sync_users_to_resend():
'total_users': total_users,
'existing_contacts': len(resend_contacts),
'added_contacts': 0,
'skipped_invalid_emails': 0,
'errors': 0,
}
@@ -355,6 +382,12 @@ def sync_users_to_resend():
logger.debug(f'User {email} already exists in Resend, skipping')
continue
# Validate email format before attempting to add to Resend
if not is_valid_email(email):
logger.warning(f'Skipping user with invalid email format: {email}')
stats['skipped_invalid_emails'] += 1
continue
try:
first_name = user.get('first_name')
last_name = user.get('last_name')

View File

@@ -141,12 +141,14 @@ def test_custom_to_static_conversion():
def create_provider_tokens(
tokens_dict: dict[ProviderType, str],
) -> dict[ProviderType, ProviderToken]:
"""Helper to create provider tokens dictionary."""
return {
provider_type: ProviderToken(token=SecretStr(token_value))
for provider_type, token_value in tokens_dict.items()
}
) -> MappingProxyType:
"""Helper to create provider tokens as MappingProxyType."""
return MappingProxyType(
{
provider_type: ProviderToken(token=SecretStr(token_value))
for provider_type, token_value in tokens_dict.items()
}
)
@pytest.mark.asyncio
@@ -264,3 +266,63 @@ async def test_get_latest_token_can_be_used_with_static_secret(
# Assert - this should NOT raise a ValidationError
static_secret = StaticSecret(value=token, description='GITHUB authentication token')
assert static_secret.get_value() == token_value
# ---------------------------------------------------------------------------
# Tests for get_authenticated_git_url - ensuring proper authenticated URLs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_authenticated_git_url_raises_when_no_tokens(
resolver_context, mock_saas_user_auth
):
"""Test that get_authenticated_git_url raises error when no provider tokens available."""
# Arrange
mock_saas_user_auth.get_provider_tokens = AsyncMock(return_value=None)
# Act & Assert
with pytest.raises(ValueError, match='No provider tokens available'):
await resolver_context.get_authenticated_git_url('owner/repo')
@pytest.mark.asyncio
async def test_get_provider_handler_caches_instance(
resolver_context, mock_saas_user_auth
):
"""Test that _get_provider_handler caches the handler instance."""
# Arrange
token_value = 'ghp_test_token'
provider_tokens = create_provider_tokens({ProviderType.GITHUB: token_value})
mock_saas_user_auth.get_provider_tokens = AsyncMock(return_value=provider_tokens)
mock_saas_user_auth.get_user_id = AsyncMock(return_value='test-user-id')
# Act - call _get_provider_handler twice
handler1 = await resolver_context._get_provider_handler()
handler2 = await resolver_context._get_provider_handler()
# Assert - should be the same instance (cached)
assert handler1 is handler2
# get_provider_tokens should only be called once
assert mock_saas_user_auth.get_provider_tokens.call_count == 1
@pytest.mark.asyncio
async def test_get_provider_handler_creates_handler_with_correct_params(
resolver_context, mock_saas_user_auth
):
"""Test that _get_provider_handler creates ProviderHandler with correct parameters."""
# Arrange
token_value = 'ghp_test_token'
provider_tokens = create_provider_tokens({ProviderType.GITHUB: token_value})
mock_saas_user_auth.get_provider_tokens = AsyncMock(return_value=provider_tokens)
mock_saas_user_auth.get_user_id = AsyncMock(return_value='test-user-id')
# Act
handler = await resolver_context._get_provider_handler()
# Assert
from openhands.integrations.provider import ProviderHandler
assert isinstance(handler, ProviderHandler)
assert handler.provider_tokens == provider_tokens

View File

@@ -6,6 +6,7 @@ import httpx
import pytest
from fastapi import HTTPException
from server.routes.api_keys import (
check_byor_permitted,
delete_byor_key_from_litellm,
get_llm_api_key_for_byor,
)
@@ -182,16 +183,18 @@ class TestGetLlmApiKeyForByor:
"""Test the get_llm_api_key_for_byor endpoint."""
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
@patch('server.routes.api_keys.store_byor_key_in_db')
@patch('server.routes.api_keys.generate_byor_key')
@patch('server.routes.api_keys.get_byor_key_from_db')
async def test_no_key_in_database_generates_new(
self, mock_get_key, mock_generate_key, mock_store_key
self, mock_get_key, mock_generate_key, mock_store_key, mock_check_enabled
):
"""Test that when no key exists in database, a new one is generated."""
# Arrange
user_id = 'user-123'
new_key = 'sk-new-generated-key'
mock_check_enabled.return_value = True
mock_get_key.return_value = None
mock_generate_key.return_value = new_key
mock_store_key.return_value = None
@@ -201,20 +204,23 @@ class TestGetLlmApiKeyForByor:
# Assert
assert result == {'key': new_key}
mock_check_enabled.assert_called_once_with(user_id)
mock_get_key.assert_called_once_with(user_id)
mock_generate_key.assert_called_once_with(user_id)
mock_store_key.assert_called_once_with(user_id, new_key)
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
@patch('storage.lite_llm_manager.LiteLlmManager.verify_key')
@patch('server.routes.api_keys.get_byor_key_from_db')
async def test_valid_key_in_database_returns_key(
self, mock_get_key, mock_verify_key
self, mock_get_key, mock_verify_key, mock_check_enabled
):
"""Test that when a valid key exists in database, it is returned."""
# Arrange
user_id = 'user-123'
existing_key = 'sk-existing-valid-key'
mock_check_enabled.return_value = True
mock_get_key.return_value = existing_key
mock_verify_key.return_value = True
@@ -223,10 +229,12 @@ class TestGetLlmApiKeyForByor:
# Assert
assert result == {'key': existing_key}
mock_check_enabled.assert_called_once_with(user_id)
mock_get_key.assert_called_once_with(user_id)
mock_verify_key.assert_called_once_with(existing_key, user_id)
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
@patch('server.routes.api_keys.store_byor_key_in_db')
@patch('server.routes.api_keys.generate_byor_key')
@patch('server.routes.api_keys.delete_byor_key_from_litellm')
@@ -239,12 +247,14 @@ class TestGetLlmApiKeyForByor:
mock_delete_key,
mock_generate_key,
mock_store_key,
mock_check_enabled,
):
"""Test that when an invalid key exists in database, it is regenerated."""
# Arrange
user_id = 'user-123'
invalid_key = 'sk-invalid-key'
new_key = 'sk-new-generated-key'
mock_check_enabled.return_value = True
mock_get_key.return_value = invalid_key
mock_verify_key.return_value = False
mock_delete_key.return_value = True
@@ -256,6 +266,7 @@ class TestGetLlmApiKeyForByor:
# Assert
assert result == {'key': new_key}
mock_check_enabled.assert_called_once_with(user_id)
mock_get_key.assert_called_once_with(user_id)
mock_verify_key.assert_called_once_with(invalid_key, user_id)
mock_delete_key.assert_called_once_with(user_id, invalid_key)
@@ -263,6 +274,7 @@ class TestGetLlmApiKeyForByor:
mock_store_key.assert_called_once_with(user_id, new_key)
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
@patch('server.routes.api_keys.store_byor_key_in_db')
@patch('server.routes.api_keys.generate_byor_key')
@patch('server.routes.api_keys.delete_byor_key_from_litellm')
@@ -275,12 +287,14 @@ class TestGetLlmApiKeyForByor:
mock_delete_key,
mock_generate_key,
mock_store_key,
mock_check_enabled,
):
"""Test that even if deletion fails, regeneration still proceeds."""
# Arrange
user_id = 'user-123'
invalid_key = 'sk-invalid-key'
new_key = 'sk-new-generated-key'
mock_check_enabled.return_value = True
mock_get_key.return_value = invalid_key
mock_verify_key.return_value = False
mock_delete_key.return_value = False # Deletion fails
@@ -292,19 +306,22 @@ class TestGetLlmApiKeyForByor:
# Assert
assert result == {'key': new_key}
mock_check_enabled.assert_called_once_with(user_id)
mock_delete_key.assert_called_once_with(user_id, invalid_key)
mock_generate_key.assert_called_once_with(user_id)
mock_store_key.assert_called_once_with(user_id, new_key)
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
@patch('server.routes.api_keys.generate_byor_key')
@patch('server.routes.api_keys.get_byor_key_from_db')
async def test_key_generation_failure_raises_exception(
self, mock_get_key, mock_generate_key
self, mock_get_key, mock_generate_key, mock_check_enabled
):
"""Test that when key generation fails, an HTTPException is raised."""
# Arrange
user_id = 'user-123'
mock_check_enabled.return_value = True
mock_get_key.return_value = None
mock_generate_key.return_value = None
@@ -316,11 +333,15 @@ class TestGetLlmApiKeyForByor:
assert 'Failed to generate new BYOR LLM API key' in exc_info.value.detail
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
@patch('server.routes.api_keys.get_byor_key_from_db')
async def test_database_error_raises_exception(self, mock_get_key):
async def test_database_error_raises_exception(
self, mock_get_key, mock_check_enabled
):
"""Test that database errors are properly handled."""
# Arrange
user_id = 'user-123'
mock_check_enabled.return_value = True
mock_get_key.side_effect = Exception('Database connection error')
# Act & Assert
@@ -330,6 +351,21 @@ class TestGetLlmApiKeyForByor:
assert exc_info.value.status_code == 500
assert 'Failed to retrieve BYOR LLM API key' in exc_info.value.detail
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
async def test_byor_export_disabled_returns_402(self, mock_check_enabled):
"""Test that when BYOR export is disabled, 402 is returned."""
# Arrange
user_id = 'user-123'
mock_check_enabled.return_value = False
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await get_llm_api_key_for_byor(user_id=user_id)
assert exc_info.value.status_code == 402
assert 'BYOR key export is not enabled' in exc_info.value.detail
class TestDeleteByorKeyFromLitellm:
"""Test the delete_byor_key_from_litellm function with alias cleanup."""
@@ -425,3 +461,52 @@ class TestDeleteByorKeyFromLitellm:
# Assert
assert result is False
class TestCheckByorPermitted:
"""Test the check_byor_permitted endpoint."""
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
async def test_permitted_when_enabled(self, mock_check_enabled):
"""Test that permitted=True is returned when BYOR export is enabled."""
# Arrange
user_id = 'user-123'
mock_check_enabled.return_value = True
# Act
result = await check_byor_permitted(user_id=user_id)
# Assert
assert result == {'permitted': True}
mock_check_enabled.assert_called_once_with(user_id)
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
async def test_not_permitted_when_disabled(self, mock_check_enabled):
"""Test that permitted=False is returned when BYOR export is disabled."""
# Arrange
user_id = 'user-123'
mock_check_enabled.return_value = False
# Act
result = await check_byor_permitted(user_id=user_id)
# Assert
assert result == {'permitted': False}
mock_check_enabled.assert_called_once_with(user_id)
@pytest.mark.asyncio
@patch('storage.org_service.OrgService.check_byor_export_enabled')
async def test_error_raises_500(self, mock_check_enabled):
"""Test that an exception raises 500 error."""
# Arrange
user_id = 'user-123'
mock_check_enabled.side_effect = Exception('Database error')
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await check_byor_permitted(user_id=user_id)
assert exc_info.value.status_code == 500
assert 'Failed to check BYOR export permission' in exc_info.value.detail

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
"""Tests for resend_keycloak email validation."""
from sync.resend_keycloak import is_valid_email
class TestIsValidEmail:
"""Test cases for is_valid_email function."""
def test_valid_simple_email(self):
"""Test that a simple valid email passes validation."""
assert is_valid_email('user@example.com') is True
def test_valid_email_with_plus(self):
"""Test that email with + modifier passes validation."""
assert is_valid_email('user+tag@example.com') is True
def test_valid_email_with_dots(self):
"""Test that email with dots in local part passes validation."""
assert is_valid_email('first.last@example.com') is True
def test_valid_email_with_numbers(self):
"""Test that email with numbers passes validation."""
assert is_valid_email('user123@example.com') is True
def test_valid_email_with_subdomain(self):
"""Test that email with subdomain passes validation."""
assert is_valid_email('user@mail.example.com') is True
def test_valid_email_with_hyphen_domain(self):
"""Test that email with hyphen in domain passes validation."""
assert is_valid_email('user@example-site.com') is True
def test_valid_email_with_underscore(self):
"""Test that email with underscore passes validation."""
assert is_valid_email('user_name@example.com') is True
def test_valid_email_with_percent(self):
"""Test that email with percent sign passes validation."""
assert is_valid_email('user%name@example.com') is True
def test_invalid_email_with_exclamation(self):
"""Test that email with exclamation mark fails validation.
This is the specific case from the bug report:
ethanjames3713+!@gmail.com
"""
assert is_valid_email('ethanjames3713+!@gmail.com') is False
def test_invalid_email_with_special_chars(self):
"""Test that email with other special characters fails validation."""
assert is_valid_email('user!name@example.com') is False
assert is_valid_email('user#name@example.com') is False
assert is_valid_email('user$name@example.com') is False
assert is_valid_email('user&name@example.com') is False
assert is_valid_email("user'name@example.com") is False
assert is_valid_email('user*name@example.com') is False
assert is_valid_email('user=name@example.com') is False
assert is_valid_email('user^name@example.com') is False
assert is_valid_email('user`name@example.com') is False
assert is_valid_email('user{name@example.com') is False
assert is_valid_email('user|name@example.com') is False
assert is_valid_email('user}name@example.com') is False
assert is_valid_email('user~name@example.com') is False
def test_invalid_email_no_at_symbol(self):
"""Test that email without @ symbol fails validation."""
assert is_valid_email('userexample.com') is False
def test_invalid_email_no_domain(self):
"""Test that email without domain fails validation."""
assert is_valid_email('user@') is False
def test_invalid_email_no_local_part(self):
"""Test that email without local part fails validation."""
assert is_valid_email('@example.com') is False
def test_invalid_email_no_tld(self):
"""Test that email without TLD fails validation."""
assert is_valid_email('user@example') is False
def test_invalid_email_single_char_tld(self):
"""Test that email with single character TLD fails validation."""
assert is_valid_email('user@example.c') is False
def test_invalid_email_empty_string(self):
"""Test that empty string fails validation."""
assert is_valid_email('') is False
def test_invalid_email_none(self):
"""Test that None fails validation."""
assert is_valid_email(None) is False
def test_invalid_email_whitespace(self):
"""Test that email with whitespace fails validation."""
assert is_valid_email('user @example.com') is False
assert is_valid_email('user@ example.com') is False
assert is_valid_email(' user@example.com') is False
assert is_valid_email('user@example.com ') is False
def test_invalid_email_double_at(self):
"""Test that email with double @ fails validation."""
assert is_valid_email('user@@example.com') is False
def test_email_double_dot_domain(self):
"""Test email with double dot in domain.
Note: The regex allows this as it's technically valid in some edge cases,
and Resend's API may accept it. The main goal is to reject special
characters like ! that Resend definitely rejects.
"""
# This is allowed by our regex - Resend may or may not accept it
assert is_valid_email('user@example..com') is True
def test_case_insensitive_validation(self):
"""Test that validation works for uppercase emails."""
assert is_valid_email('USER@EXAMPLE.COM') is True
assert is_valid_email('User@Example.Com') is True

View File

@@ -0,0 +1,491 @@
"""
Unit tests for role-based authorization (authorization.py).
Tests the FastAPI dependencies that validate user roles within organizations.
"""
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from fastapi import HTTPException
from server.auth.authorization import (
ROLE_HIERARCHY,
OrgRole,
get_user_org_role,
has_required_role,
require_org_admin,
require_org_owner,
require_org_role,
require_org_user,
)
# =============================================================================
# Tests for OrgRole enum
# =============================================================================
class TestOrgRole:
"""Tests for OrgRole enum."""
def test_org_role_values(self):
"""
GIVEN: OrgRole enum
WHEN: Accessing role values
THEN: All expected roles exist with correct string values
"""
assert OrgRole.OWNER.value == 'owner'
assert OrgRole.ADMIN.value == 'admin'
assert OrgRole.USER.value == 'user'
def test_org_role_from_string(self):
"""
GIVEN: Valid role string
WHEN: Creating OrgRole from string
THEN: Correct enum value is returned
"""
assert OrgRole('owner') == OrgRole.OWNER
assert OrgRole('admin') == OrgRole.ADMIN
assert OrgRole('user') == OrgRole.USER
def test_org_role_invalid_string(self):
"""
GIVEN: Invalid role string
WHEN: Creating OrgRole from string
THEN: ValueError is raised
"""
with pytest.raises(ValueError):
OrgRole('invalid_role')
# =============================================================================
# Tests for role hierarchy
# =============================================================================
class TestRoleHierarchy:
"""Tests for role hierarchy constants."""
def test_owner_highest_rank(self):
"""
GIVEN: Role hierarchy
WHEN: Comparing role ranks
THEN: Owner has highest rank
"""
assert ROLE_HIERARCHY[OrgRole.OWNER] > ROLE_HIERARCHY[OrgRole.ADMIN]
assert ROLE_HIERARCHY[OrgRole.OWNER] > ROLE_HIERARCHY[OrgRole.USER]
def test_admin_middle_rank(self):
"""
GIVEN: Role hierarchy
WHEN: Comparing role ranks
THEN: Admin is between owner and user
"""
assert ROLE_HIERARCHY[OrgRole.ADMIN] > ROLE_HIERARCHY[OrgRole.USER]
assert ROLE_HIERARCHY[OrgRole.ADMIN] < ROLE_HIERARCHY[OrgRole.OWNER]
def test_user_lowest_rank(self):
"""
GIVEN: Role hierarchy
WHEN: Comparing role ranks
THEN: User has lowest rank
"""
assert ROLE_HIERARCHY[OrgRole.USER] < ROLE_HIERARCHY[OrgRole.ADMIN]
assert ROLE_HIERARCHY[OrgRole.USER] < ROLE_HIERARCHY[OrgRole.OWNER]
# =============================================================================
# Tests for has_required_role function
# =============================================================================
class TestHasRequiredRole:
"""Tests for has_required_role function."""
def test_owner_has_owner_role(self):
"""
GIVEN: User with owner role
WHEN: Checking for owner requirement
THEN: Returns True
"""
assert has_required_role('owner', OrgRole.OWNER) is True
def test_owner_has_admin_role(self):
"""
GIVEN: User with owner role
WHEN: Checking for admin requirement
THEN: Returns True (owner > admin)
"""
assert has_required_role('owner', OrgRole.ADMIN) is True
def test_owner_has_user_role(self):
"""
GIVEN: User with owner role
WHEN: Checking for user requirement
THEN: Returns True (owner > user)
"""
assert has_required_role('owner', OrgRole.USER) is True
def test_admin_has_admin_role(self):
"""
GIVEN: User with admin role
WHEN: Checking for admin requirement
THEN: Returns True
"""
assert has_required_role('admin', OrgRole.ADMIN) is True
def test_admin_has_user_role(self):
"""
GIVEN: User with admin role
WHEN: Checking for user requirement
THEN: Returns True (admin > user)
"""
assert has_required_role('admin', OrgRole.USER) is True
def test_admin_lacks_owner_role(self):
"""
GIVEN: User with admin role
WHEN: Checking for owner requirement
THEN: Returns False (admin < owner)
"""
assert has_required_role('admin', OrgRole.OWNER) is False
def test_user_has_user_role(self):
"""
GIVEN: User with user role
WHEN: Checking for user requirement
THEN: Returns True
"""
assert has_required_role('user', OrgRole.USER) is True
def test_user_lacks_admin_role(self):
"""
GIVEN: User with user role
WHEN: Checking for admin requirement
THEN: Returns False (user < admin)
"""
assert has_required_role('user', OrgRole.ADMIN) is False
def test_user_lacks_owner_role(self):
"""
GIVEN: User with user role
WHEN: Checking for owner requirement
THEN: Returns False (user < owner)
"""
assert has_required_role('user', OrgRole.OWNER) is False
def test_invalid_role_returns_false(self):
"""
GIVEN: Invalid role string
WHEN: Checking for any requirement
THEN: Returns False
"""
assert has_required_role('invalid_role', OrgRole.USER) is False
assert has_required_role('invalid_role', OrgRole.ADMIN) is False
assert has_required_role('invalid_role', OrgRole.OWNER) is False
# =============================================================================
# Tests for get_user_org_role function
# =============================================================================
class TestGetUserOrgRole:
"""Tests for get_user_org_role function."""
def test_returns_role_when_member_exists(self):
"""
GIVEN: User is a member of organization with role
WHEN: get_user_org_role is called
THEN: Role name is returned
"""
user_id = str(uuid4())
org_id = uuid4()
mock_org_member = MagicMock()
mock_org_member.role_id = 1
mock_role = MagicMock()
mock_role.name = 'admin'
with (
patch(
'server.auth.authorization.OrgMemberStore.get_org_member',
return_value=mock_org_member,
),
patch(
'server.auth.authorization.RoleStore.get_role_by_id',
return_value=mock_role,
),
):
result = get_user_org_role(user_id, org_id)
assert result == 'admin'
def test_returns_none_when_not_member(self):
"""
GIVEN: User is not a member of organization
WHEN: get_user_org_role is called
THEN: None is returned
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.OrgMemberStore.get_org_member',
return_value=None,
):
result = get_user_org_role(user_id, org_id)
assert result is None
def test_returns_none_when_role_not_found(self):
"""
GIVEN: User is member but role not found
WHEN: get_user_org_role is called
THEN: None is returned
"""
user_id = str(uuid4())
org_id = uuid4()
mock_org_member = MagicMock()
mock_org_member.role_id = 999 # Non-existent role
with (
patch(
'server.auth.authorization.OrgMemberStore.get_org_member',
return_value=mock_org_member,
),
patch(
'server.auth.authorization.RoleStore.get_role_by_id',
return_value=None,
),
):
result = get_user_org_role(user_id, org_id)
assert result is None
# =============================================================================
# Tests for require_org_role dependency
# =============================================================================
class TestRequireOrgRole:
"""Tests for require_org_role dependency factory."""
@pytest.mark.asyncio
async def test_returns_user_id_when_authorized(self):
"""
GIVEN: User with sufficient role
WHEN: Role checker is called
THEN: User ID is returned
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value='admin',
):
role_checker = require_org_role(OrgRole.USER)
result = await role_checker(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
async def test_raises_401_when_not_authenticated(self):
"""
GIVEN: No user ID (not authenticated)
WHEN: Role checker is called
THEN: 401 Unauthorized is raised
"""
org_id = uuid4()
role_checker = require_org_role(OrgRole.USER)
with pytest.raises(HTTPException) as exc_info:
await role_checker(org_id=org_id, user_id=None)
assert exc_info.value.status_code == 401
assert 'not authenticated' in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_raises_403_when_not_member(self):
"""
GIVEN: User is not a member of organization
WHEN: Role checker is called
THEN: 403 Forbidden is raised
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value=None,
):
role_checker = require_org_role(OrgRole.USER)
with pytest.raises(HTTPException) as exc_info:
await role_checker(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == 403
assert 'not a member' in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_raises_403_when_insufficient_role(self):
"""
GIVEN: User with insufficient role
WHEN: Role checker is called
THEN: 403 Forbidden is raised
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value='user',
):
role_checker = require_org_role(OrgRole.ADMIN)
with pytest.raises(HTTPException) as exc_info:
await role_checker(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == 403
assert 'admin' in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_owner_satisfies_admin_requirement(self):
"""
GIVEN: User with owner role
WHEN: Admin role is required
THEN: User ID is returned (owner > admin)
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value='owner',
):
role_checker = require_org_role(OrgRole.ADMIN)
result = await role_checker(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
async def test_logs_warning_on_insufficient_role(self):
"""
GIVEN: User with insufficient role
WHEN: Role checker is called
THEN: Warning is logged with details
"""
user_id = str(uuid4())
org_id = uuid4()
with (
patch(
'server.auth.authorization.get_user_org_role',
return_value='user',
),
patch('server.auth.authorization.logger') as mock_logger,
):
role_checker = require_org_role(OrgRole.OWNER)
with pytest.raises(HTTPException):
await role_checker(org_id=org_id, user_id=user_id)
mock_logger.warning.assert_called()
call_args = mock_logger.warning.call_args
assert call_args[1]['extra']['user_id'] == user_id
assert call_args[1]['extra']['user_role'] == 'user'
assert call_args[1]['extra']['required_role'] == 'owner'
# =============================================================================
# Tests for convenience dependencies
# =============================================================================
class TestConvenienceDependencies:
"""Tests for pre-configured convenience dependencies."""
@pytest.mark.asyncio
async def test_require_org_user_allows_user(self):
"""
GIVEN: User with user role
WHEN: require_org_user is used
THEN: User ID is returned
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value='user',
):
result = await require_org_user(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
async def test_require_org_admin_allows_admin(self):
"""
GIVEN: User with admin role
WHEN: require_org_admin is used
THEN: User ID is returned
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value='admin',
):
result = await require_org_admin(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
async def test_require_org_admin_rejects_user(self):
"""
GIVEN: User with user role
WHEN: require_org_admin is used
THEN: 403 Forbidden is raised
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value='user',
):
with pytest.raises(HTTPException) as exc_info:
await require_org_admin(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_require_org_owner_allows_owner(self):
"""
GIVEN: User with owner role
WHEN: require_org_owner is used
THEN: User ID is returned
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value='owner',
):
result = await require_org_owner(org_id=org_id, user_id=user_id)
assert result == user_id
@pytest.mark.asyncio
async def test_require_org_owner_rejects_admin(self):
"""
GIVEN: User with admin role
WHEN: require_org_owner is used
THEN: 403 Forbidden is raised
"""
user_id = str(uuid4())
org_id = uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
return_value='admin',
):
with pytest.raises(HTTPException) as exc_info:
await require_org_owner(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == 403

View File

@@ -299,6 +299,8 @@ async def test_success_callback_success():
mock_billing_session.status = 'in_progress'
mock_billing_session.user_id = 'mock_user'
mock_org = MagicMock()
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
patch('stripe.checkout.Session.retrieve') as mock_stripe_retrieve,
@@ -319,7 +321,17 @@ async def test_success_callback_success():
) as mock_update_budget,
):
mock_db_session = MagicMock()
# First query: BillingSession (query().filter().filter().first())
mock_db_session.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_billing_session
# Second query: Org (query().filter().first()) - use side_effect for different return chains
mock_query_chain_billing = MagicMock()
mock_query_chain_billing.filter.return_value.filter.return_value.first.return_value = mock_billing_session
mock_query_chain_org = MagicMock()
mock_query_chain_org.filter.return_value.first.return_value = mock_org
mock_db_session.query.side_effect = [
mock_query_chain_billing,
mock_query_chain_org,
]
mock_session_maker.return_value.__enter__.return_value = mock_db_session
mock_stripe_retrieve.return_value = MagicMock(
@@ -340,6 +352,9 @@ async def test_success_callback_success():
125.0, # 100 + (25.00 from Stripe)
)
# Verify BYOR export is enabled for the org (updated in same session)
assert mock_org.byor_export_enabled is True
# Verify database updates
assert mock_billing_session.status == 'completed'
assert mock_billing_session.price == 25.0

View File

@@ -1,8 +1,15 @@
import uuid
from unittest.mock import patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# Mock the database module before importing OrgMemberStore
with patch('storage.database.engine'), patch('storage.database.a_engine'):
with patch('storage.database.engine', create=True), patch(
'storage.database.a_engine', create=True
):
from storage.base import Base
from storage.org import Org
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
@@ -10,6 +17,31 @@ with patch('storage.database.engine'), patch('storage.database.a_engine'):
from storage.user import User
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker for testing."""
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
def test_get_org_members(session_maker):
# Test getting org_members by org ID
with session_maker() as session:
@@ -251,3 +283,324 @@ def test_remove_user_from_org_not_found(session_maker):
with patch('storage.org_member_store.session_maker', session_maker):
result = OrgMemberStore.remove_user_from_org(uuid4(), 99999)
assert result is False
@pytest.mark.asyncio
async def test_get_org_members_paginated_basic(async_session_maker):
"""Test basic pagination returns correct number of items."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
# Create 5 users
users = [
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
for i in range(5)
]
session.add_all(users)
await session.flush()
# Create org members
org_members = [
OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
)
for i, user in enumerate(users)
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=3
)
# Assert
assert len(members) == 3
assert has_more is True
# Verify user and role relationships are loaded
assert all(member.user is not None for member in members)
assert all(member.role is not None for member in members)
@pytest.mark.asyncio
async def test_get_org_members_paginated_no_more(async_session_maker):
"""Test pagination when there are no more results."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
# Create 3 users
users = [
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
for i in range(3)
]
session.add_all(users)
await session.flush()
# Create org members
org_members = [
OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
)
for i, user in enumerate(users)
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=5
)
# Assert
assert len(members) == 3
assert has_more is False
@pytest.mark.asyncio
async def test_get_org_members_paginated_exact_limit(async_session_maker):
"""Test pagination when results exactly match limit."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
# Create exactly 5 users
users = [
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
for i in range(5)
]
session.add_all(users)
await session.flush()
# Create org members
org_members = [
OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
)
for i, user in enumerate(users)
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=5
)
# Assert
assert len(members) == 5
assert has_more is False
@pytest.mark.asyncio
async def test_get_org_members_paginated_with_offset(async_session_maker):
"""Test pagination with offset skips correct number of items."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
# Create 10 users
users = [
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
for i in range(10)
]
session.add_all(users)
await session.flush()
# Create org members
org_members = [
OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
)
for i, user in enumerate(users)
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act - Get first page
with patch('storage.org_member_store.a_session_maker', async_session_maker):
first_page, has_more_first = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=3
)
# Get second page
second_page, has_more_second = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=3, limit=3
)
# Assert
assert len(first_page) == 3
assert has_more_first is True
assert len(second_page) == 3
assert has_more_second is True
# Verify no overlap between pages
first_user_ids = {member.user_id for member in first_page}
second_user_ids = {member.user_id for member in second_page}
assert first_user_ids.isdisjoint(second_user_ids)
@pytest.mark.asyncio
async def test_get_org_members_paginated_empty_org(async_session_maker):
"""Test pagination with empty organization returns empty list."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=10
)
# Assert
assert len(members) == 0
assert has_more is False
@pytest.mark.asyncio
async def test_get_org_members_paginated_ordering(async_session_maker):
"""Test that pagination orders results by user_id."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='admin', rank=1)
session.add(role)
await session.flush()
# Create users with specific IDs to test ordering
user_ids = [uuid.uuid4() for _ in range(5)]
user_ids.sort() # Sort to verify ordering
users = [
User(id=user_id, current_org_id=org.id, email=f'user{i}@example.com')
for i, user_id in enumerate(user_ids)
]
session.add_all(users)
await session.flush()
# Create org members in reverse order to test that ordering works
org_members = [
OrgMember(
org_id=org.id,
user_id=user_id,
role_id=role.id,
llm_api_key=f'test-key-{i}',
status='active',
)
for i, user_id in enumerate(reversed(user_ids))
]
session.add_all(org_members)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=10
)
# Assert
assert len(members) == 5
# Verify members are ordered by user_id
member_user_ids = [member.user_id for member in members]
assert member_user_ids == sorted(member_user_ids)
@pytest.mark.asyncio
async def test_get_org_members_paginated_eager_loading(async_session_maker):
"""Test that user and role relationships are eagerly loaded."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org')
session.add(org)
await session.flush()
role = Role(name='owner', rank=10)
session.add(role)
await session.flush()
user = User(id=uuid.uuid4(), current_org_id=org.id, email='test@example.com')
session.add(user)
await session.flush()
org_member = OrgMember(
org_id=org.id,
user_id=user.id,
role_id=role.id,
llm_api_key='test-key',
status='active',
)
session.add(org_member)
await session.commit()
org_id = org.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=0, limit=10
)
# Assert
assert len(members) == 1
member = members[0]
# Verify relationships are loaded (not lazy)
assert member.user is not None
assert member.user.email == 'test@example.com'
assert member.role is not None
assert member.role.name == 'owner'
assert member.role.rank == 10

View File

@@ -1535,6 +1535,118 @@ async def test_update_org_with_permissions_database_error(session_maker):
assert 'Failed to update organization' in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_org_with_permissions_duplicate_name_raises_org_name_exists_error(
session_maker,
):
"""
GIVEN: User updates org name to a name already used by another organization
WHEN: update_org_with_permissions is called
THEN: OrgNameExistsError is raised with the conflicting name
"""
# Arrange
org_id = uuid.uuid4()
other_org_id = uuid.uuid4()
user_id = str(uuid.uuid4())
duplicate_name = 'Existing Org Name'
mock_current_org = Org(
id=org_id,
name='My Org',
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
)
mock_org_with_name = Org(
id=other_org_id,
name=duplicate_name,
contact_name='Jane Doe',
contact_email='jane@example.com',
)
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(name=duplicate_name)
with (
patch('storage.org_store.session_maker', session_maker),
patch('storage.org_member_store.session_maker', session_maker),
patch('storage.role_store.session_maker', session_maker),
patch(
'storage.org_service.OrgStore.get_org_by_id',
return_value=mock_current_org,
),
patch('storage.org_service.OrgService.is_org_member', return_value=True),
patch(
'storage.org_service.OrgStore.get_org_by_name',
return_value=mock_org_with_name,
),
):
# Act & Assert
with pytest.raises(OrgNameExistsError) as exc_info:
await OrgService.update_org_with_permissions(
org_id=org_id,
update_data=update_data,
user_id=user_id,
)
assert duplicate_name in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_org_with_permissions_same_name_allowed(session_maker):
"""
GIVEN: User updates org with name unchanged (same as current org name)
WHEN: update_org_with_permissions is called
THEN: No OrgNameExistsError; update proceeds (name uniqueness allows same org)
"""
# Arrange
org_id = uuid.uuid4()
user_id = str(uuid.uuid4())
current_name = 'My Org'
mock_org = Org(
id=org_id,
name=current_name,
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
)
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(name=current_name)
with (
patch('storage.org_store.session_maker', session_maker),
patch('storage.org_member_store.session_maker', session_maker),
patch('storage.role_store.session_maker', session_maker),
patch(
'storage.org_service.OrgStore.get_org_by_id',
return_value=mock_org,
),
patch('storage.org_service.OrgService.is_org_member', return_value=True),
patch(
'storage.org_service.OrgStore.get_org_by_name',
return_value=mock_org,
),
patch(
'storage.org_service.OrgStore.update_org',
return_value=mock_org,
),
):
# Act
result = await OrgService.update_org_with_permissions(
org_id=org_id,
update_data=update_data,
user_id=user_id,
)
# Assert
assert result is not None
assert result.name == current_name
@pytest.mark.asyncio
async def test_update_org_with_permissions_only_llm_fields(session_maker):
"""
@@ -1657,3 +1769,258 @@ async def test_update_org_with_permissions_only_non_llm_fields(session_maker):
assert result.contact_name == 'Jane Doe'
assert result.conversation_expiration == 60
assert result.enable_proactive_conversation_starters is False
@pytest.mark.asyncio
async def test_check_byor_export_enabled_returns_true_when_enabled():
"""
GIVEN: User has current_org with byor_export_enabled=True
WHEN: check_byor_export_enabled is called
THEN: Returns True
"""
# Arrange
user_id = 'test-user-123'
org_id = uuid.uuid4()
mock_user = MagicMock()
mock_user.current_org_id = org_id
mock_org = MagicMock()
mock_org.byor_export_enabled = True
with (
patch(
'storage.org_service.UserStore.get_user_by_id_async',
AsyncMock(return_value=mock_user),
),
patch(
'storage.org_service.OrgStore.get_org_by_id',
return_value=mock_org,
),
):
# Act
result = await OrgService.check_byor_export_enabled(user_id)
# Assert
assert result is True
@pytest.mark.asyncio
async def test_check_byor_export_enabled_returns_false_when_disabled():
"""
GIVEN: User has current_org with byor_export_enabled=False
WHEN: check_byor_export_enabled is called
THEN: Returns False
"""
# Arrange
user_id = 'test-user-123'
org_id = uuid.uuid4()
mock_user = MagicMock()
mock_user.current_org_id = org_id
mock_org = MagicMock()
mock_org.byor_export_enabled = False
with (
patch(
'storage.org_service.UserStore.get_user_by_id_async',
AsyncMock(return_value=mock_user),
),
patch(
'storage.org_service.OrgStore.get_org_by_id',
return_value=mock_org,
),
):
# Act
result = await OrgService.check_byor_export_enabled(user_id)
# Assert
assert result is False
@pytest.mark.asyncio
async def test_check_byor_export_enabled_returns_false_when_user_not_found():
"""
GIVEN: User does not exist
WHEN: check_byor_export_enabled is called
THEN: Returns False
"""
# Arrange
user_id = 'nonexistent-user'
with patch(
'storage.org_service.UserStore.get_user_by_id_async',
AsyncMock(return_value=None),
):
# Act
result = await OrgService.check_byor_export_enabled(user_id)
# Assert
assert result is False
@pytest.mark.asyncio
async def test_check_byor_export_enabled_returns_false_when_no_current_org():
"""
GIVEN: User exists but has no current_org_id
WHEN: check_byor_export_enabled is called
THEN: Returns False
"""
# Arrange
user_id = 'test-user-123'
mock_user = MagicMock()
mock_user.current_org_id = None
with patch(
'storage.org_service.UserStore.get_user_by_id_async',
AsyncMock(return_value=mock_user),
):
# Act
result = await OrgService.check_byor_export_enabled(user_id)
# Assert
assert result is False
@pytest.mark.asyncio
async def test_check_byor_export_enabled_returns_false_when_org_not_found():
"""
GIVEN: User has current_org_id but org does not exist
WHEN: check_byor_export_enabled is called
THEN: Returns False
"""
# Arrange
user_id = 'test-user-123'
org_id = uuid.uuid4()
mock_user = MagicMock()
mock_user.current_org_id = org_id
with (
patch(
'storage.org_service.UserStore.get_user_by_id_async',
AsyncMock(return_value=mock_user),
),
patch(
'storage.org_service.OrgStore.get_org_by_id',
return_value=None,
),
):
# Act
result = await OrgService.check_byor_export_enabled(user_id)
# Assert
assert result is False
@pytest.mark.asyncio
async def test_switch_org_success():
"""
GIVEN: Valid org_id and user_id where user is a member
WHEN: switch_org is called
THEN: User's current_org_id is updated and org is returned
"""
# Arrange
org_id = uuid.uuid4()
user_id = str(uuid.uuid4())
mock_org = Org(
id=org_id,
name='Target Organization',
contact_name='John Doe',
contact_email='john@example.com',
)
mock_updated_user = User(id=uuid.UUID(user_id), current_org_id=org_id)
with (
patch('storage.org_service.OrgStore.get_org_by_id', return_value=mock_org),
patch('storage.org_service.OrgService.is_org_member', return_value=True),
patch(
'storage.org_service.UserStore.update_current_org',
return_value=mock_updated_user,
),
):
# Act
result = await OrgService.switch_org(user_id, org_id)
# Assert
assert result is not None
assert result.id == org_id
assert result.name == 'Target Organization'
@pytest.mark.asyncio
async def test_switch_org_org_not_found():
"""
GIVEN: Organization does not exist
WHEN: switch_org is called
THEN: OrgNotFoundError is raised
"""
# Arrange
org_id = uuid.uuid4()
user_id = str(uuid.uuid4())
with patch('storage.org_service.OrgStore.get_org_by_id', return_value=None):
# Act & Assert
with pytest.raises(OrgNotFoundError) as exc_info:
await OrgService.switch_org(user_id, org_id)
assert str(org_id) in str(exc_info.value)
@pytest.mark.asyncio
async def test_switch_org_user_not_member():
"""
GIVEN: User is not a member of the organization
WHEN: switch_org is called
THEN: OrgAuthorizationError is raised
"""
# Arrange
org_id = uuid.uuid4()
user_id = str(uuid.uuid4())
mock_org = Org(
id=org_id,
name='Target Organization',
contact_name='John Doe',
contact_email='john@example.com',
)
with (
patch('storage.org_service.OrgStore.get_org_by_id', return_value=mock_org),
patch('storage.org_service.OrgService.is_org_member', return_value=False),
):
# Act & Assert
with pytest.raises(OrgAuthorizationError) as exc_info:
await OrgService.switch_org(user_id, org_id)
assert 'member' in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_switch_org_user_not_found():
"""
GIVEN: User does not exist in database
WHEN: switch_org is called
THEN: OrgDatabaseError is raised
"""
# Arrange
org_id = uuid.uuid4()
user_id = str(uuid.uuid4())
mock_org = Org(
id=org_id,
name='Target Organization',
contact_name='John Doe',
contact_email='john@example.com',
)
with (
patch('storage.org_service.OrgStore.get_org_by_id', return_value=mock_org),
patch('storage.org_service.OrgService.is_org_member', return_value=True),
patch('storage.org_service.UserStore.update_current_org', return_value=None),
):
# Act & Assert
with pytest.raises(OrgDatabaseError) as exc_info:
await OrgService.switch_org(user_id, org_id)
assert 'User not found' in str(exc_info.value)

View File

@@ -786,3 +786,23 @@ def test_get_user_orgs_paginated_ordering(session_maker, mock_litellm_api):
assert orgs[0].name == 'Apple Org'
assert orgs[1].name == 'Banana Org'
assert orgs[2].name == 'Zebra Org'
def test_orphaned_user_error_contains_user_ids():
"""
GIVEN: OrphanedUserError is created with a list of user IDs
WHEN: The error message is accessed
THEN: Message includes the count and stores user IDs
"""
# Arrange
from server.routes.org_models import OrphanedUserError
user_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
# Act
error = OrphanedUserError(user_ids)
# Assert
assert error.user_ids == user_ids
assert '2 user(s)' in str(error)
assert 'no remaining organization' in str(error)

View File

@@ -3,7 +3,9 @@ from unittest.mock import MagicMock, patch
import pytest
# Mock the database module before importing
with patch('storage.database.engine'), patch('storage.database.a_engine'):
with patch('storage.database.engine', create=True), patch(
'storage.database.a_engine', create=True
):
from integrations.github.github_view import get_user_proactive_conversation_setting
from storage.org import Org

View File

@@ -522,3 +522,46 @@ async def test_backfill_contact_name_preserves_custom_value(session_maker):
with session_maker() as session:
org = session.query(Org).filter(Org.id == uuid.UUID(user_id)).first()
assert org.contact_name == 'Custom Corp Name'
def test_update_current_org_success(session_maker):
"""
GIVEN: User exists in database
WHEN: update_current_org is called with new org_id
THEN: User's current_org_id is updated and user is returned
"""
# Arrange
user_id = str(uuid.uuid4())
initial_org_id = uuid.uuid4()
new_org_id = uuid.uuid4()
with session_maker() as session:
user = User(id=uuid.UUID(user_id), current_org_id=initial_org_id)
session.add(user)
session.commit()
# Act
with patch('storage.user_store.session_maker', session_maker):
result = UserStore.update_current_org(user_id, new_org_id)
# Assert
assert result is not None
assert result.current_org_id == new_org_id
def test_update_current_org_user_not_found(session_maker):
"""
GIVEN: User does not exist in database
WHEN: update_current_org is called
THEN: None is returned
"""
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
# Act
with patch('storage.user_store.session_maker', session_maker):
result = UserStore.update_current_org(user_id, org_id)
# Assert
assert result is None

View File

@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
vi.mock("#/api/open-hands-axios", () => ({
openHands: { get: mockGet },
}));
describe("V1ConversationService", () => {
describe("readConversationFile", () => {
it("uses default plan path when filePath is not provided", async () => {
// Arrange
const conversationId = "conv-123";
mockGet.mockResolvedValue({ data: "# PLAN content" });
// Act
await V1ConversationService.readConversationFile(conversationId);
// Assert
expect(mockGet).toHaveBeenCalledTimes(1);
const callUrl = mockGet.mock.calls[0][0] as string;
expect(callUrl).toContain(
"file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md",
);
});
});
});

View File

@@ -125,7 +125,7 @@ describe("ChatInterface - Chat Suggestions", () => {
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { APP_MODE: "local" },
data: { app_mode: "local" },
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
@@ -258,7 +258,7 @@ describe("ChatInterface - Empty state", () => {
errorMessage: null,
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { APP_MODE: "local" },
data: { app_mode: "local" },
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),

View File

@@ -113,15 +113,15 @@ describe("ExpandableMessage", () => {
it("should render the out of credits message when the user is out of credits", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields
// @ts-expect-error - We only care about the app_mode and feature_flags fields
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});
const RouterStub = createRoutesStub([

View File

@@ -0,0 +1,250 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router";
import { render } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { useParamsMock, createUserMessageEvent } from "test-utils";
import { ChatInterface } from "#/components/features/chat/chat-interface";
import { useWsClient } from "#/context/ws-client-provider";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
import { useConfig } from "#/hooks/query/use-config";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { useEventStore } from "#/stores/use-event-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
import { OpenHandsAction } from "#/types/core/actions";
// Module-level mocks
vi.mock("#/context/ws-client-provider");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-unified-upload-files");
vi.mock("#/hooks/use-conversation-id");
vi.mock("#/hooks/query/use-active-conversation");
vi.mock("#/contexts/conversation-websocket-context");
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
}),
}));
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(() => ({
curAgentState: AgentState.AWAITING_USER_INPUT,
})),
}));
// Helper to render with QueryClient and route params
const renderWithQueryClient = (
ui: React.ReactElement,
queryClient: QueryClient,
route = "/test-conversation-id",
) =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/:conversationId" element={ui} />
<Route path="/" element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
// V0 user event (numeric id, action property)
const createV0UserEvent = (): OpenHandsAction => ({
id: 1,
source: "user",
action: "message",
args: {
content: "Hello from V0",
image_urls: [],
file_urls: [],
},
message: "Hello from V0",
timestamp: "2025-07-01T00:00:00Z",
});
describe("ChatInterface message display continuity (spec 3.1)", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" });
vi.mocked(useConversationId).mockReturnValue({
conversationId: "test-conversation-id",
});
// Default: V0, no loading, no events
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { app_mode: "local" },
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
});
(
useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
isLoading: false,
});
// Default: no conversation (V0 behavior)
vi.mocked(useActiveConversation).mockReturnValue({
data: undefined,
} as ReturnType<typeof useActiveConversation>);
// Default: no websocket context
vi.mocked(useConversationWebSocket).mockReturnValue(null);
});
describe("V1 conversations", () => {
beforeEach(() => {
// Set up V1 conversation
vi.mocked(useActiveConversation).mockReturnValue({
data: { conversation_version: "V1" },
} as ReturnType<typeof useActiveConversation>);
});
it("shows messages immediately when V1 events exist in store, even while loading", () => {
// Simulate: history is loading but events already exist in store (e.g., remount)
vi.mocked(useConversationWebSocket).mockReturnValue({
isLoadingHistory: true,
connectionState: "OPEN",
sendMessage: vi.fn(),
});
// Put V1 user events in the store
const v1UserEvent = createUserMessageEvent("evt-1");
useEventStore.setState({
events: [v1UserEvent],
uiEvents: [v1UserEvent],
});
renderWithQueryClient(<ChatInterface />, queryClient);
// AC1: Messages should display immediately without skeleton
expect(
screen.queryByTestId("chat-messages-skeleton"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
});
it("shows skeleton when store is empty and loading", () => {
// Simulate: first load, no events yet
vi.mocked(useConversationWebSocket).mockReturnValue({
isLoadingHistory: true,
connectionState: "OPEN",
sendMessage: vi.fn(),
});
// Store is empty
useEventStore.setState({
events: [],
uiEvents: [],
});
renderWithQueryClient(<ChatInterface />, queryClient);
// AC5: Genuine first-load shows skeleton
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
});
it("shows messages when loading is already false on mount (edge case)", () => {
// Simulate: component re-mounts when WebSocket has already finished loading
vi.mocked(useConversationWebSocket).mockReturnValue({
isLoadingHistory: false,
connectionState: "OPEN",
sendMessage: vi.fn(),
});
// V1 events in store
const v1UserEvent = createUserMessageEvent("evt-2");
useEventStore.setState({
events: [v1UserEvent],
uiEvents: [v1UserEvent],
});
renderWithQueryClient(<ChatInterface />, queryClient);
// Messages should display, no skeleton
expect(
screen.queryByTestId("chat-messages-skeleton"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
});
});
describe("V0 conversations", () => {
it("shows messages when V0 events exist in store even if isLoadingMessages is true", () => {
// Simulate: loading flag is still true but events already exist in store (e.g., remount)
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: true,
parsedEvents: [],
});
// Put V0 user events in the store
useEventStore.setState({
events: [createV0UserEvent()],
uiEvents: [],
});
renderWithQueryClient(<ChatInterface />, queryClient);
// AC1/AC4: Messages display immediately, no skeleton
expect(
screen.queryByTestId("chat-messages-skeleton"),
).not.toBeInTheDocument();
});
it("shows skeleton when store is empty and isLoadingMessages is true", () => {
// Simulate: genuine first load, no events yet
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: true,
parsedEvents: [],
});
// Store is empty
useEventStore.setState({
events: [],
uiEvents: [],
});
renderWithQueryClient(<ChatInterface />, queryClient);
// AC5: Genuine first-load shows skeleton
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
});
});
});

View File

@@ -5,7 +5,7 @@ import { EventMessage } from "#/components/features/chat/event-message";
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
}),
}));

View File

@@ -0,0 +1,287 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act } from "react";
import { MemoryRouter } from "react-router";
import { AlertBanner } from "#/components/features/alerts/alert-banner";
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { time?: string }) => {
const translations: Record<string, string> = {
MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
ALERT$FAULTY_MODELS_MESSAGE:
"The following models are currently reporting errors:",
"ERROR$TRANSLATED_KEY": "This is a translated error message",
};
return translations[key] || key;
},
}),
};
});
describe("AlertBanner", () => {
afterEach(() => {
localStorage.clear();
});
describe("Maintenance alerts", () => {
it("renders maintenance banner with formatted time", () => {
const startTime = "2024-01-15T10:00:00-05:00";
const updatedAt = "2024-01-14T10:00:00Z";
const { container } = render(
<MemoryRouter>
<AlertBanner maintenanceStartTime={startTime} updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
expect(banner).toBeInTheDocument();
const svgIcon = container.querySelector("svg");
expect(svgIcon).toBeInTheDocument();
const button = within(banner!).queryByTestId("dismiss-button");
expect(button).toBeInTheDocument();
});
it("click on dismiss button removes banner", () => {
const startTime = "2024-01-15T10:00:00-05:00";
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner maintenanceStartTime={startTime} updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
});
it("banner reappears when updatedAt changes", () => {
const startTime = "2024-01-15T10:00:00-05:00";
const updatedAt = "2024-01-14T10:00:00Z";
const newUpdatedAt = "2024-01-15T10:00:00Z";
const { rerender } = render(
<MemoryRouter>
<AlertBanner maintenanceStartTime={startTime} updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
rerender(
<MemoryRouter>
<AlertBanner
maintenanceStartTime={startTime}
updatedAt={newUpdatedAt}
/>
</MemoryRouter>,
);
expect(screen.queryByTestId("alert-banner")).toBeInTheDocument();
});
});
describe("Faulty models alerts", () => {
it("renders banner with faulty models list", () => {
const faultyModels = ["gpt-4", "claude-3"];
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner faultyModels={faultyModels} updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
expect(banner).toBeInTheDocument();
expect(
screen.getByText(/The following models are currently reporting errors:/),
).toBeInTheDocument();
// Models are displayed in the order they are provided
expect(screen.getByText(/gpt-4/)).toBeInTheDocument();
expect(screen.getByText(/claude-3/)).toBeInTheDocument();
});
it("does not render banner when faulty models array is empty", () => {
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner faultyModels={[]} updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
expect(banner).not.toBeInTheDocument();
});
it("banner reappears when updatedAt changes", () => {
const faultyModels = ["gpt-4"];
const updatedAt = "2024-01-14T10:00:00Z";
const newUpdatedAt = "2024-01-15T10:00:00Z";
const { rerender } = render(
<MemoryRouter>
<AlertBanner faultyModels={faultyModels} updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
rerender(
<MemoryRouter>
<AlertBanner faultyModels={faultyModels} updatedAt={newUpdatedAt} />
</MemoryRouter>,
);
expect(screen.queryByTestId("alert-banner")).toBeInTheDocument();
});
});
describe("Error message alerts", () => {
it("renders banner with translated error message", () => {
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner errorMessage="ERROR$TRANSLATED_KEY" updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
expect(banner).toBeInTheDocument();
expect(
screen.getByText("This is a translated error message"),
).toBeInTheDocument();
});
it("renders banner with raw error message when no translation exists", () => {
const rawErrorMessage = "This is a raw error without translation";
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner errorMessage={rawErrorMessage} updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
expect(banner).toBeInTheDocument();
expect(screen.getByText(rawErrorMessage)).toBeInTheDocument();
});
it("does not render banner when error message is empty", () => {
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner errorMessage="" updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
expect(banner).not.toBeInTheDocument();
});
it("does not render banner when error message is null", () => {
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner errorMessage={null} updatedAt={updatedAt} />
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
expect(banner).not.toBeInTheDocument();
});
});
describe("Multiple alerts", () => {
it("renders all alerts when multiple conditions are present", () => {
const startTime = "2024-01-15T10:00:00-05:00";
const faultyModels = ["gpt-4"];
const errorMessage = "ERROR$TRANSLATED_KEY";
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner
maintenanceStartTime={startTime}
faultyModels={faultyModels}
errorMessage={errorMessage}
updatedAt={updatedAt}
/>
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
expect(banner).toBeInTheDocument();
expect(
screen.getByText(/Scheduled maintenance will begin at/),
).toBeInTheDocument();
expect(
screen.getByText(/The following models are currently reporting errors:/),
).toBeInTheDocument();
expect(
screen.getByText("This is a translated error message"),
).toBeInTheDocument();
});
it("dismissing hides all alerts", () => {
const startTime = "2024-01-15T10:00:00-05:00";
const faultyModels = ["gpt-4"];
const updatedAt = "2024-01-14T10:00:00Z";
render(
<MemoryRouter>
<AlertBanner
maintenanceStartTime={startTime}
faultyModels={faultyModels}
updatedAt={updatedAt}
/>
</MemoryRouter>,
);
const banner = screen.queryByTestId("alert-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
});
});
});

View File

@@ -92,19 +92,21 @@ describe("PlanPreview", () => {
});
it("should render nothing when planContent is null", () => {
renderPlanPreview(<PlanPreview planContent={null} />);
// Arrange & Act
const { container } = renderPlanPreview(<PlanPreview planContent={null} />);
const contentDiv = screen.getByTestId("plan-preview-content");
expect(contentDiv).toBeInTheDocument();
expect(contentDiv.textContent?.trim() || "").toBe("");
// Assert
expect(container.firstChild).toBeNull();
});
it("should render nothing when planContent is undefined", () => {
renderPlanPreview(<PlanPreview planContent={undefined} />);
// Arrange & Act
const { container } = renderPlanPreview(
<PlanPreview planContent={undefined} />,
);
const contentDiv = screen.getByTestId("plan-preview-content");
expect(contentDiv).toBeInTheDocument();
expect(contentDiv.textContent?.trim() || "").toBe("");
// Assert
expect(container.firstChild).toBeNull();
});
it("should render markdown content when planContent is provided", () => {
@@ -170,7 +172,7 @@ describe("PlanPreview", () => {
// Arrange
const user = userEvent.setup();
const expectedPrompt =
"Execute the plan based on the workspace/project/PLAN.md file.";
"Execute the plan based on the .agents_tmp/PLAN.md file.";
renderPlanPreview(<PlanPreview planContent="Plan content" />);
const buildButton = screen.getByTestId("plan-preview-build-button");
@@ -201,7 +203,7 @@ describe("PlanPreview", () => {
useOptimisticUserMessageStore.setState({ optimisticUserMessage: null });
const user = userEvent.setup();
const expectedPrompt =
"Execute the plan based on the workspace/project/PLAN.md file.";
"Execute the plan based on the .agents_tmp/PLAN.md file.";
renderPlanPreview(<PlanPreview planContent="Plan content" />);
const buildButton = screen.getByTestId("plan-preview-build-button");

View File

@@ -0,0 +1,112 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mutable mock state for controlling breakpoint
let mockIsMobile = false;
// Track ChatInterface unmount via vi.fn()
const chatInterfaceUnmount = vi.fn();
vi.mock("#/hooks/use-breakpoint", () => ({
useBreakpoint: () => mockIsMobile,
}));
vi.mock("#/hooks/use-resizable-panels", () => ({
useResizablePanels: () => ({
leftWidth: 50,
rightWidth: 50,
isDragging: false,
containerRef: { current: null },
handleMouseDown: vi.fn(),
}),
}));
vi.mock("#/stores/conversation-store", () => ({
useConversationStore: () => ({
isRightPanelShown: false,
}),
}));
// Mock ChatInterface with useEffect to track mount/unmount lifecycle
vi.mock("#/components/features/chat/chat-interface", () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require("react");
return {
ChatInterface: () => {
React.useEffect(() => {
return () => chatInterfaceUnmount();
}, []);
return <div data-testid="chat-interface">Chat Interface</div>;
},
};
});
vi.mock(
"#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content",
() => ({
ConversationTabContent: () => <div data-testid="tab-content" />,
}),
);
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
describe("ConversationMain - Layout Transition Stability", () => {
beforeEach(() => {
mockIsMobile = false;
chatInterfaceUnmount.mockClear();
});
it("renders ChatInterface at desktop width", () => {
mockIsMobile = false;
render(<ConversationMain />);
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
});
it("renders ChatInterface at mobile width", () => {
mockIsMobile = true;
render(<ConversationMain />);
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
});
it("does not unmount ChatInterface when crossing from desktop to mobile", () => {
mockIsMobile = false;
const { rerender } = render(<ConversationMain />);
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
// Cross the breakpoint to mobile
mockIsMobile = true;
rerender(<ConversationMain />);
// ChatInterface must NOT have been unmounted and remounted
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
});
it("does not unmount ChatInterface when crossing from mobile to desktop", () => {
mockIsMobile = true;
const { rerender } = render(<ConversationMain />);
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
// Cross the breakpoint to desktop
mockIsMobile = false;
rerender(<ConversationMain />);
// ChatInterface must NOT have been unmounted and remounted
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
});
it("survives rapid back-and-forth resize without unmounting ChatInterface", () => {
mockIsMobile = false;
const { rerender } = render(<ConversationMain />);
// Simulate rapid resize back and forth across the breakpoint
for (const mobile of [true, false, true, false, true]) {
mockIsMobile = mobile;
rerender(<ConversationMain />);
}
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
});
});

View File

@@ -33,7 +33,7 @@ const {
})),
useConfigMock: vi.fn(() => ({
data: {
APP_MODE: "oss",
app_mode: "oss",
},
})),
}));
@@ -659,7 +659,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
app_mode: "saas",
},
});
@@ -685,7 +685,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
app_mode: "saas",
},
});
@@ -718,7 +718,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
app_mode: "saas",
},
});
@@ -751,7 +751,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
app_mode: "saas",
},
});
@@ -781,7 +781,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
app_mode: "saas",
},
});
@@ -810,7 +810,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
app_mode: "saas",
},
});
});

View File

@@ -177,10 +177,10 @@ describe("RepoConnector", () => {
it("should render the 'add github repos' link in dropdown if saas mode and github provider is set", async () => {
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the APP_MODE and APP_SLUG
// @ts-expect-error - only return the app_mode and github_app_slug
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
APP_SLUG: "openhands",
app_mode: "saas",
github_app_slug: "openhands",
});
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
@@ -224,10 +224,10 @@ describe("RepoConnector", () => {
it("should not render the 'add github repos' link if github provider is not set", async () => {
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the APP_MODE and APP_SLUG
// @ts-expect-error - only return the app_mode and github_app_slug for this test
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
APP_SLUG: "openhands",
app_mode: "saas",
github_app_slug: "openhands",
});
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
@@ -269,9 +269,9 @@ describe("RepoConnector", () => {
it("should not render the 'add github repos' link in dropdown if oss mode", async () => {
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the APP_MODE
// @ts-expect-error - only return the app_mode
getConfiSpy.mockResolvedValue({
APP_MODE: "oss",
app_mode: "oss",
});
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");

View File

@@ -30,7 +30,7 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
}),
}));

View File

@@ -1,119 +0,0 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act } from "react";
import { MemoryRouter } from "react-router";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { time?: string }) => {
const translations: Record<string, string> = {
MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
};
return translations[key] || key;
},
}),
};
});
describe("MaintenanceBanner", () => {
afterEach(() => {
localStorage.clear();
});
it("renders maintenance banner with formatted time", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const { container } = render(
<MemoryRouter>
<MaintenanceBanner startTime={startTime} />
</MemoryRouter>,
);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
expect(banner).toBeInTheDocument();
// Check if the warning icon (SVG) is present
const svgIcon = container.querySelector("svg");
expect(svgIcon).toBeInTheDocument();
// Check if the button to close is present
const button = within(banner!).queryByTestId("dismiss-button");
expect(button).toBeInTheDocument();
});
it("handles invalid date gracefully", () => {
// Suppress expected console.warn for invalid date parsing
const consoleWarnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});
const invalidTime = "invalid-date";
render(
<MemoryRouter>
<MaintenanceBanner startTime={invalidTime} />
</MemoryRouter>,
);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
expect(banner).not.toBeInTheDocument();
// Restore console.warn
consoleWarnSpy.mockRestore();
});
it("click on dismiss button removes banner", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
render(
<MemoryRouter>
<MaintenanceBanner startTime={startTime} />
</MemoryRouter>,
);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
});
it("banner reappears after dismissing on next maintenance event(future time)", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const nextStartTime = "2025-01-15T10:00:00-05:00"; // EST timestamp
const { rerender } = render(
<MemoryRouter>
<MaintenanceBanner startTime={startTime} />
</MemoryRouter>,
);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
rerender(
<MemoryRouter>
<MaintenanceBanner startTime={nextStartTime} />
</MemoryRouter>,
);
expect(screen.queryByTestId("maintenance-banner")).toBeInTheDocument();
});
});

View File

@@ -305,7 +305,7 @@ describe("MicroagentManagement", () => {
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "oss",
app_mode: "oss",
},
});

View File

@@ -27,17 +27,17 @@ describe("PaymentForm", () => {
const renderPaymentForm = () => renderWithProviders(<PaymentForm />);
beforeEach(() => {
// useBalance hook will return the balance only if the APP_MODE is "saas" and the billing feature is enabled
// useBalance hook will return the balance only if the app_mode is "saas" and the billing feature is enabled
// @ts-expect-error - partial mock for testing
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
posthog_client_key: "456",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});
});

View File

@@ -9,27 +9,34 @@ import { Sidebar } from "#/components/features/sidebar/sidebar";
import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { WebClientConfig } from "#/api/option-service/option.types";
// Helper to create mock config with sensible defaults
const createMockConfig = (
overrides: Omit<Partial<GetConfigResponse>, "FEATURE_FLAGS"> & {
FEATURE_FLAGS?: Partial<GetConfigResponse["FEATURE_FLAGS"]>;
overrides: Omit<Partial<WebClientConfig>, "feature_flags"> & {
feature_flags?: Partial<WebClientConfig["feature_flags"]>;
} = {},
): GetConfigResponse => {
const { FEATURE_FLAGS: featureFlagOverrides, ...restOverrides } = overrides;
): WebClientConfig => {
const { feature_flags: featureFlagOverrides, ...restOverrides } = overrides;
return {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "oss",
posthog_client_key: "test-posthog-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
...featureFlagOverrides,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
...restOverrides,
};
};
@@ -76,9 +83,9 @@ describe("Sidebar", () => {
});
describe("Settings modal auto-open behavior", () => {
it("should NOT open settings modal when HIDE_LLM_SETTINGS is true even with 404 error", async () => {
it("should NOT open settings modal when hide_llm_settings is true even with 404 error", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: true } }),
createMockConfig({ feature_flags: { hide_llm_settings: true } }),
);
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
@@ -89,21 +96,21 @@ describe("Sidebar", () => {
expect(getSettingsSpy).toHaveBeenCalled();
});
// Settings modal should NOT appear when HIDE_LLM_SETTINGS is true
// Settings modal should NOT appear when hide_llm_settings is true
await waitFor(() => {
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
});
});
it("should open settings modal when HIDE_LLM_SETTINGS is false and 404 error in OSS mode", async () => {
it("should open settings modal when hide_llm_settings is false and 404 error in OSS mode", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
createMockConfig({ feature_flags: { hide_llm_settings: false } }),
);
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
renderSidebar();
// Settings modal should appear when HIDE_LLM_SETTINGS is false
// Settings modal should appear when hide_llm_settings is false
await waitFor(() => {
expect(screen.getByTestId("ai-config-modal")).toBeInTheDocument();
});
@@ -112,8 +119,8 @@ describe("Sidebar", () => {
it("should NOT open settings modal in SaaS mode even with 404 error", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({
APP_MODE: "saas",
FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false },
app_mode: "saas",
feature_flags: { hide_llm_settings: false },
}),
);
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
@@ -133,7 +140,7 @@ describe("Sidebar", () => {
it("should NOT open settings modal when settings exist (no 404 error)", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
createMockConfig({ feature_flags: { hide_llm_settings: false } }),
);
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
@@ -152,7 +159,7 @@ describe("Sidebar", () => {
it("should NOT open settings modal when on /settings path", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
createMockConfig({ feature_flags: { hide_llm_settings: false } }),
);
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());

View File

@@ -22,7 +22,7 @@ describe("PostHogWrapper", () => {
// Mock the config fetch
// @ts-expect-error - partial mock
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
POSTHOG_CLIENT_KEY: "test-posthog-key",
posthog_client_key: "test-posthog-key",
});
});

View File

@@ -13,7 +13,7 @@ const useIsAuthedMock = vi
const useConfigMock = vi
.fn()
.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
.mockReturnValue({ data: { app_mode: "saas" }, isLoading: false });
const useUserProvidersMock = vi
.fn()
@@ -46,7 +46,7 @@ describe("UserActions", () => {
// Reset all mocks to default values before each test
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
@@ -89,7 +89,7 @@ describe("UserActions", () => {
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
@@ -126,7 +126,7 @@ describe("UserActions", () => {
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
@@ -154,7 +154,7 @@ describe("UserActions", () => {
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
@@ -179,7 +179,7 @@ describe("UserActions", () => {
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
// Ensure config and providers are set correctly
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
@@ -211,7 +211,7 @@ describe("UserActions", () => {
// Start with authentication and providers
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
@@ -236,7 +236,7 @@ describe("UserActions", () => {
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
@@ -265,7 +265,7 @@ describe("UserActions", () => {
// Ensure authentication and providers are set correctly
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
data: { app_mode: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({

View File

@@ -0,0 +1,29 @@
import { WebClientConfig } from "#/api/option-service/option.types";
/**
* Creates a mock WebClientConfig with all required fields.
* Use this helper to create test config objects with sensible defaults.
*/
export const createMockWebClientConfig = (
overrides: Partial<WebClientConfig> = {},
): WebClientConfig => ({
app_mode: "oss",
posthog_client_key: "test-posthog-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
...overrides.feature_flags,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: null,
...overrides,
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -112,3 +112,192 @@ describe("useConversationHistory", () => {
expect(EventService.searchEventsV1).not.toHaveBeenCalled();
});
});
describe("useConversationHistory cache key stability", () => {
let localQueryClient: QueryClient;
let localWrapper: ({
children,
}: {
children: React.ReactNode;
}) => React.ReactElement;
beforeEach(() => {
localQueryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
localWrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(
QueryClientProvider,
{ client: localQueryClient },
children,
);
});
afterEach(() => {
localQueryClient.clear();
vi.clearAllMocks();
});
it("does not refetch when conversation object changes but version stays the same", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
const conv1 = makeConversation("V1");
vi.mocked(useUserConversation).mockReturnValue({
data: conv1,
isLoading: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
} as any);
const { result, rerender } = renderHook(
() => useConversationHistory("conv-stable"),
{ wrapper: localWrapper },
);
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
expect(v1Spy).toHaveBeenCalledTimes(1);
// Simulate background polling: new object reference with different mutable fields
// but the SAME conversation_version
const conv2: Conversation = {
...conv1,
last_updated_at: "2099-01-01T00:00:00Z",
status: "STOPPED",
runtime_status: "STATUS$STOPPED",
};
vi.mocked(useUserConversation).mockReturnValue({
data: conv2,
isLoading: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
} as any);
rerender();
// Allow any potential async refetch to trigger
await new Promise((r) => {
setTimeout(r, 50);
});
// Must NOT refetch — version hasn't changed, only mutable fields did
expect(v1Spy).toHaveBeenCalledTimes(1);
});
// Edge case: version change MUST trigger a refetch with the correct endpoint
it("refetches when conversation_version changes from V0 to V1", async () => {
const v0Spy = vi.spyOn(EventService, "searchEventsV0");
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v0Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue([makeEvent()]);
// Start with V0
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V0"),
isLoading: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
} as any);
const { result, rerender } = renderHook(
() => useConversationHistory("conv-version-change"),
{ wrapper: localWrapper },
);
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
expect(v0Spy).toHaveBeenCalledTimes(1);
// Switch to V1 — new version means new cache key, must refetch
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),
isLoading: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
} as any);
rerender();
await waitFor(() => {
expect(v1Spy).toHaveBeenCalledTimes(1);
});
});
it("treats cached history as never stale (staleTime is Infinity)", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),
isLoading: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
} as any);
const { result } = renderHook(
() => useConversationHistory("conv-stale-check"),
{ wrapper: localWrapper },
);
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
// Check the query's staleTime option in the cache
const queries = localQueryClient.getQueryCache().findAll({
queryKey: ["conversation-history", "conv-stale-check"],
});
expect(queries).toHaveLength(1);
expect((queries[0].options as Record<string, unknown>).staleTime).toBe(
Infinity,
);
});
it("has gcTime of at least 30 minutes for navigation resilience", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),
isLoading: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
} as any);
const { result } = renderHook(
() => useConversationHistory("conv-gc-check"),
{ wrapper: localWrapper },
);
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
const queries = localQueryClient.getQueryCache().findAll({
queryKey: ["conversation-history", "conv-gc-check"],
});
expect(queries).toHaveLength(1);
expect(queries[0].options.gcTime).toBeGreaterThanOrEqual(30 * 60 * 1000);
});
});

View File

@@ -0,0 +1,180 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useBreakpoint } from "#/hooks/use-breakpoint";
// Helper to set window.innerWidth and dispatch resize event
function setWindowWidth(width: number) {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: width,
});
window.dispatchEvent(new Event("resize"));
}
describe("useBreakpoint", () => {
const originalInnerWidth = window.innerWidth;
beforeEach(() => {
// Start at a known desktop width
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1200,
});
});
afterEach(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: originalInnerWidth,
});
});
it("returns false (not mobile) when window width is above the breakpoint", () => {
Object.defineProperty(window, "innerWidth", { value: 1200 });
const { result } = renderHook(() => useBreakpoint());
expect(result.current).toBe(false);
});
it("returns true (mobile) when window width is at the breakpoint (1024)", () => {
Object.defineProperty(window, "innerWidth", { value: 1024 });
const { result } = renderHook(() => useBreakpoint());
expect(result.current).toBe(true);
});
it("returns true (mobile) when window width is below the breakpoint", () => {
Object.defineProperty(window, "innerWidth", { value: 800 });
const { result } = renderHook(() => useBreakpoint());
expect(result.current).toBe(true);
});
it("updates from false to true when window resizes below the breakpoint", () => {
Object.defineProperty(window, "innerWidth", { value: 1200 });
const { result } = renderHook(() => useBreakpoint());
expect(result.current).toBe(false);
act(() => {
setWindowWidth(800);
});
expect(result.current).toBe(true);
});
it("updates from true to false when window resizes above the breakpoint", () => {
Object.defineProperty(window, "innerWidth", { value: 800 });
const { result } = renderHook(() => useBreakpoint());
expect(result.current).toBe(true);
act(() => {
setWindowWidth(1200);
});
expect(result.current).toBe(false);
});
it("does NOT trigger re-render when width changes within the desktop range", () => {
Object.defineProperty(window, "innerWidth", { value: 1200 });
const renderCount = vi.fn();
const { result } = renderHook(() => {
renderCount();
return useBreakpoint();
});
expect(result.current).toBe(false);
const initialRenderCount = renderCount.mock.calls.length;
// Resize within desktop range (still above 1024) — should NOT re-render
act(() => {
setWindowWidth(1300);
});
act(() => {
setWindowWidth(1100);
});
act(() => {
setWindowWidth(1025);
});
expect(result.current).toBe(false);
// No additional renders beyond the initial render
expect(renderCount.mock.calls.length).toBe(initialRenderCount);
});
it("does NOT trigger re-render when width changes within the mobile range", () => {
Object.defineProperty(window, "innerWidth", { value: 800 });
const renderCount = vi.fn();
const { result } = renderHook(() => {
renderCount();
return useBreakpoint();
});
expect(result.current).toBe(true);
const initialRenderCount = renderCount.mock.calls.length;
// Resize within mobile range (still at or below 1024) — should NOT re-render
act(() => {
setWindowWidth(600);
});
act(() => {
setWindowWidth(1024);
});
act(() => {
setWindowWidth(900);
});
expect(result.current).toBe(true);
expect(renderCount.mock.calls.length).toBe(initialRenderCount);
});
it("handles rapid resize across the breakpoint without issues", () => {
Object.defineProperty(window, "innerWidth", { value: 1200 });
const { result } = renderHook(() => useBreakpoint());
expect(result.current).toBe(false);
// Rapid toggles across the breakpoint
act(() => {
setWindowWidth(800);
});
expect(result.current).toBe(true);
act(() => {
setWindowWidth(1200);
});
expect(result.current).toBe(false);
act(() => {
setWindowWidth(1024);
});
expect(result.current).toBe(true);
act(() => {
setWindowWidth(1025);
});
expect(result.current).toBe(false);
});
it("cleans up the resize event listener on unmount", () => {
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
const { unmount } = renderHook(() => useBreakpoint());
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
"resize",
expect.any(Function),
);
removeEventListenerSpy.mockRestore();
});
it("accepts a custom breakpoint value", () => {
Object.defineProperty(window, "innerWidth", { value: 768 });
const { result } = renderHook(() => useBreakpoint(768));
expect(result.current).toBe(true);
act(() => {
setWindowWidth(769);
});
expect(result.current).toBe(false);
});
});

View File

@@ -0,0 +1,383 @@
import { describe, expect, it, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useFilteredEvents } from "#/hooks/use-filtered-events";
import { useEventStore } from "#/stores/use-event-store";
import type { OpenHandsAction } from "#/types/core/actions";
import type { ActionEvent, MessageEvent } from "#/types/v1/core";
import { SecurityRisk } from "#/types/v1/core";
// --- V0 event factories ---
function createV0UserMessage(id: number): OpenHandsAction {
return {
id,
source: "user",
action: "message",
args: { content: `User message ${id}`, image_urls: [], file_urls: [] },
message: `User message ${id}`,
timestamp: `2025-07-01T00:00:0${id}Z`,
};
}
function createV0AgentMessage(id: number): OpenHandsAction {
return {
id,
source: "agent",
action: "message",
args: {
thought: `Agent thought ${id}`,
image_urls: null,
file_urls: [],
wait_for_response: true,
},
message: `Agent response ${id}`,
timestamp: `2025-07-01T00:00:0${id}Z`,
};
}
function createV0SystemEvent(id: number): OpenHandsAction {
return {
id,
source: "environment",
action: "system",
args: {
content: "source .openhands/setup.sh",
tools: null,
openhands_version: null,
agent_class: null,
},
message: "Running setup script",
timestamp: `2025-07-01T00:00:0${id}Z`,
};
}
// --- V1 event factories ---
function createV1UserMessage(id: string): MessageEvent {
return {
id,
timestamp: "2025-07-01T00:00:01Z",
source: "user",
llm_message: {
role: "user",
content: [{ type: "text", text: `User message ${id}` }],
},
activated_microagents: [],
extended_content: [],
};
}
function createV1AgentAction(id: string): ActionEvent {
return {
id,
timestamp: "2025-07-01T00:00:02Z",
source: "agent",
thought: [{ type: "text", text: "Agent thought" }],
thinking_blocks: [],
action: {
kind: "ExecuteBashAction",
command: "echo test",
is_input: false,
timeout: null,
reset: false,
},
tool_name: "execute_bash",
tool_call_id: "call-1",
tool_call: {
id: "call-1",
type: "function",
function: { name: "execute_bash", arguments: '{"command": "echo test"}' },
},
llm_response_id: "response-1",
security_risk: SecurityRisk.UNKNOWN,
};
}
beforeEach(() => {
// Reset the event store before each test
useEventStore.setState({
events: [],
eventIds: new Set(),
uiEvents: [],
});
});
describe("useFilteredEvents", () => {
describe("referential stability", () => {
it("returns the same v0Events reference when storeEvents has not changed", () => {
const v0Event = createV0UserMessage(1);
useEventStore.setState({
events: [v0Event],
eventIds: new Set([1]),
uiEvents: [v0Event],
});
const { result, rerender } = renderHook(() => useFilteredEvents());
const firstV0Events = result.current.v0Events;
// Rerender without changing the store
rerender();
expect(result.current.v0Events).toBe(firstV0Events);
});
it("returns the same v1UiEvents reference when uiEvents has not changed", () => {
const v1Event = createV1UserMessage("msg-1");
useEventStore.setState({
events: [v1Event],
eventIds: new Set(["msg-1"]),
uiEvents: [v1Event],
});
const { result, rerender } = renderHook(() => useFilteredEvents());
const firstV1UiEvents = result.current.v1UiEvents;
rerender();
expect(result.current.v1UiEvents).toBe(firstV1UiEvents);
});
it("returns the same v1FullEvents reference when storeEvents has not changed", () => {
const v1Event = createV1UserMessage("msg-1");
useEventStore.setState({
events: [v1Event],
eventIds: new Set(["msg-1"]),
uiEvents: [v1Event],
});
const { result, rerender } = renderHook(() => useFilteredEvents());
const firstV1FullEvents = result.current.v1FullEvents;
rerender();
expect(result.current.v1FullEvents).toBe(firstV1FullEvents);
});
it("returns a new v0Events reference when storeEvents changes", () => {
const v0Event1 = createV0UserMessage(1);
useEventStore.setState({
events: [v0Event1],
eventIds: new Set([1]),
uiEvents: [v0Event1],
});
const { result } = renderHook(() => useFilteredEvents());
const firstV0Events = result.current.v0Events;
// Add a new event to the store (new array reference)
const v0Event2 = createV0AgentMessage(2);
act(() => {
useEventStore.setState({
events: [v0Event1, v0Event2],
eventIds: new Set([1, 2]),
uiEvents: [v0Event1, v0Event2],
});
});
expect(result.current.v0Events).not.toBe(firstV0Events);
expect(result.current.v0Events).toHaveLength(2);
});
});
describe("V0 event filtering", () => {
it("filters V0 events through isV0Event, isActionOrObservation, and shouldRenderEvent", () => {
const userMsg = createV0UserMessage(1);
const agentMsg = createV0AgentMessage(2);
useEventStore.setState({
events: [userMsg, agentMsg],
eventIds: new Set([1, 2]),
uiEvents: [userMsg, agentMsg],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.v0Events).toHaveLength(2);
expect(result.current.v0Events).toContainEqual(userMsg);
expect(result.current.v0Events).toContainEqual(agentMsg);
});
it("excludes V0 system events from v0Events", () => {
const userMsg = createV0UserMessage(1);
const systemEvent = createV0SystemEvent(2);
useEventStore.setState({
events: [userMsg, systemEvent],
eventIds: new Set([1, 2]),
uiEvents: [userMsg, systemEvent],
});
const { result } = renderHook(() => useFilteredEvents());
// System events are filtered out by shouldRenderEvent
expect(result.current.v0Events).toHaveLength(1);
expect(result.current.v0Events[0]).toEqual(userMsg);
});
it("does not include V1 events in v0Events", () => {
const v0Event = createV0UserMessage(1);
const v1Event = createV1UserMessage("msg-1");
useEventStore.setState({
events: [v0Event, v1Event],
eventIds: new Set([1, "msg-1"]),
uiEvents: [v0Event, v1Event],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.v0Events).toHaveLength(1);
expect(result.current.v0Events[0]).toEqual(v0Event);
});
});
describe("V1 event filtering", () => {
it("filters V1 events into v1FullEvents", () => {
const v1Event = createV1UserMessage("msg-1");
useEventStore.setState({
events: [v1Event],
eventIds: new Set(["msg-1"]),
uiEvents: [v1Event],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.v1FullEvents).toHaveLength(1);
expect(result.current.v1FullEvents[0]).toEqual(v1Event);
});
it("does not include V0 events in v1FullEvents", () => {
const v0Event = createV0UserMessage(1);
const v1Event = createV1UserMessage("msg-1");
useEventStore.setState({
events: [v0Event, v1Event],
eventIds: new Set([1, "msg-1"]),
uiEvents: [v0Event, v1Event],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.v1FullEvents).toHaveLength(1);
expect(result.current.v1FullEvents[0]).toEqual(v1Event);
});
});
describe("totalEvents", () => {
it("returns V0 event count when V0 events exist", () => {
const v0Event1 = createV0UserMessage(1);
const v0Event2 = createV0AgentMessage(2);
useEventStore.setState({
events: [v0Event1, v0Event2],
eventIds: new Set([1, 2]),
uiEvents: [v0Event1, v0Event2],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.totalEvents).toBe(2);
});
it("returns 0 when no events exist", () => {
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.totalEvents).toBe(0);
});
});
describe("hasSubstantiveAgentActions", () => {
it("returns false when no events exist", () => {
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.hasSubstantiveAgentActions).toBe(false);
});
it("returns false when only user events exist (V0)", () => {
const userMsg = createV0UserMessage(1);
useEventStore.setState({
events: [userMsg],
eventIds: new Set([1]),
uiEvents: [userMsg],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.hasSubstantiveAgentActions).toBe(false);
});
it("returns true when V0 agent message actions exist", () => {
const agentMsg = createV0AgentMessage(1);
useEventStore.setState({
events: [agentMsg],
eventIds: new Set([1]),
uiEvents: [agentMsg],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.hasSubstantiveAgentActions).toBe(true);
});
it("returns true when V1 agent action events exist", () => {
const agentAction = createV1AgentAction("action-1");
useEventStore.setState({
events: [agentAction],
eventIds: new Set(["action-1"]),
uiEvents: [agentAction],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.hasSubstantiveAgentActions).toBe(true);
});
});
describe("userEventsExist", () => {
it("returns false when no events exist", () => {
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.userEventsExist).toBe(false);
});
it("returns true when V0 user events exist", () => {
const userMsg = createV0UserMessage(1);
useEventStore.setState({
events: [userMsg],
eventIds: new Set([1]),
uiEvents: [userMsg],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.v0UserEventsExist).toBe(true);
expect(result.current.userEventsExist).toBe(true);
});
it("returns true when V1 user events exist", () => {
const userMsg = createV1UserMessage("msg-1");
useEventStore.setState({
events: [userMsg],
eventIds: new Set(["msg-1"]),
uiEvents: [userMsg],
});
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.v1UserEventsExist).toBe(true);
expect(result.current.userEventsExist).toBe(true);
});
});
describe("empty store", () => {
it("returns empty arrays and false flags for empty store", () => {
const { result } = renderHook(() => useFilteredEvents());
expect(result.current.v0Events).toEqual([]);
expect(result.current.v1UiEvents).toEqual([]);
expect(result.current.v1FullEvents).toEqual([]);
expect(result.current.totalEvents).toBe(0);
expect(result.current.hasSubstantiveAgentActions).toBe(false);
expect(result.current.v0UserEventsExist).toBe(false);
expect(result.current.v1UserEventsExist).toBe(false);
expect(result.current.userEventsExist).toBe(false);
});
});
});

View File

@@ -41,8 +41,7 @@ describe("useHandleBuildPlanClick", () => {
(createChatMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
action: "message",
args: {
content:
"Execute the plan based on the workspace/project/PLAN.md file.",
content: "Execute the plan based on the .agents_tmp/PLAN.md file.",
image_urls: [],
file_urls: [],
timestamp: expect.any(String),
@@ -78,7 +77,7 @@ describe("useHandleBuildPlanClick", () => {
// Arrange
const { result } = renderHook(() => useHandleBuildPlanClick());
const expectedPrompt =
"Execute the plan based on the workspace/project/PLAN.md file.";
"Execute the plan based on the .agents_tmp/PLAN.md file.";
// Act
act(() => {
@@ -109,7 +108,7 @@ describe("useHandleBuildPlanClick", () => {
useOptimisticUserMessageStore.setState({ optimisticUserMessage: null });
const { result } = renderHook(() => useHandleBuildPlanClick());
const expectedPrompt =
"Execute the plan based on the workspace/project/PLAN.md file.";
"Execute the plan based on the .agents_tmp/PLAN.md file.";
// Act
act(() => {
@@ -155,7 +154,7 @@ describe("useHandleBuildPlanClick", () => {
expect(useConversationStore.getState().conversationMode).toBe("code");
expect(mockSend).toHaveBeenCalledTimes(1);
expect(useOptimisticUserMessageStore.getState().optimisticUserMessage).toBe(
"Execute the plan based on the workspace/project/PLAN.md file.",
"Execute the plan based on the .agents_tmp/PLAN.md file.",
);
});

View File

@@ -0,0 +1,126 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import type { RefObject } from "react";
/**
* Creates a mock scroll element with a trackable scrollTop setter.
*
* state.scrollTop can be set directly (bypassing the spy) to position
* the element for onChatBodyScroll calls without polluting the spy.
*/
function createMockScrollElement(initialScrollHeight = 1000) {
const state = {
scrollTop: 0,
scrollHeight: initialScrollHeight,
clientHeight: 500,
};
const scrollTopSetter = vi.fn((value: number) => {
state.scrollTop = value;
});
const element = {
get scrollTop() {
return state.scrollTop;
},
set scrollTop(value: number) {
scrollTopSetter(value);
},
get scrollHeight() {
return state.scrollHeight;
},
get clientHeight() {
return state.clientHeight;
},
} as unknown as HTMLDivElement;
return { element, scrollTopSetter, state };
}
describe("useScrollToBottom", () => {
let mock: ReturnType<typeof createMockScrollElement>;
let ref: RefObject<HTMLDivElement>;
beforeEach(() => {
mock = createMockScrollElement(1000);
ref = { current: mock.element } as RefObject<HTMLDivElement>;
});
describe("no automatic scrolling on render", () => {
it("does NOT scroll on initial render", () => {
renderHook(() => useScrollToBottom(ref));
// No useLayoutEffect means no automatic scroll-to-bottom
expect(mock.scrollTopSetter).not.toHaveBeenCalled();
});
it("does NOT scroll when re-rendered (e.g., during resize)", () => {
const { rerender } = renderHook(() => useScrollToBottom(ref));
mock.state.scrollHeight = 1500;
rerender();
expect(mock.scrollTopSetter).not.toHaveBeenCalled();
});
});
describe("scroll position tracking", () => {
it("tracks hitBottom correctly via onChatBodyScroll", () => {
const { result } = renderHook(() => useScrollToBottom(ref));
// Position at bottom: scrollTop(480) + clientHeight(500) = 980 >= 1000 - 20
mock.state.scrollTop = 480;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.hitBottom).toBe(true);
// Position not at bottom: scrollTop(200) + clientHeight(500) = 700 < 980
mock.state.scrollTop = 200;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.hitBottom).toBe(false);
});
it("disables autoScroll when user scrolls up", () => {
const { result } = renderHook(() => useScrollToBottom(ref));
// First scroll to establish prevScrollTopRef
mock.state.scrollTop = 400;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
// Scroll up (lower scrollTop than previous)
mock.state.scrollTop = 200;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.autoScroll).toBe(false);
});
it("re-enables autoScroll when user reaches bottom", () => {
const { result } = renderHook(() => useScrollToBottom(ref));
// Scroll up to disable autoScroll
mock.state.scrollTop = 400;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
mock.state.scrollTop = 200;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.autoScroll).toBe(false);
// Scroll to bottom
mock.state.scrollTop = 500; // 500 + 500 = 1000 >= 980
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.autoScroll).toBe(true);
});
});
});

View File

@@ -12,8 +12,8 @@ const wrapper = ({ children }: { children: React.ReactNode }) => (
const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: appMode,
FEATURE_FLAGS: { HIDE_LLM_SETTINGS: hideLlmSettings },
app_mode: appMode,
feature_flags: { hide_llm_settings: hideLlmSettings },
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
};
@@ -22,7 +22,7 @@ describe("useSettingsNavItems", () => {
queryClient.clear();
});
it("should return SAAS_NAV_ITEMS when APP_MODE is 'saas'", async () => {
it("should return SAAS_NAV_ITEMS when app_mode is 'saas'", async () => {
mockConfig("saas");
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
@@ -31,7 +31,7 @@ describe("useSettingsNavItems", () => {
});
});
it("should return OSS_NAV_ITEMS when APP_MODE is 'oss'", async () => {
it("should return OSS_NAV_ITEMS when app_mode is 'oss'", async () => {
mockConfig("oss");
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
@@ -40,7 +40,7 @@ describe("useSettingsNavItems", () => {
});
});
it("should filter out '/settings' item when HIDE_LLM_SETTINGS feature flag is enabled", async () => {
it("should filter out '/settings' item when hide_llm_settings feature flag is enabled", async () => {
mockConfig("saas", true);
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });

View File

@@ -17,11 +17,11 @@ describe("frontend/routes/_oh", () => {
const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted(
() => {
const defaultFeatureFlags = {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
};
return {
@@ -33,7 +33,7 @@ describe("frontend/routes/_oh", () => {
isError: false,
}),
useConfigMock: vi.fn().mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: defaultFeatureFlags },
data: { app_mode: "oss", feature_flags: defaultFeatureFlags },
isLoading: false,
}),
};
@@ -84,7 +84,7 @@ describe("frontend/routes/_oh", () => {
isError: false,
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
@@ -108,7 +108,7 @@ describe("frontend/routes/_oh", () => {
isError: false,
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
@@ -129,16 +129,16 @@ describe("frontend/routes/_oh", () => {
"handleCaptureConsent",
);
// @ts-expect-error - partial mock for testing
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-id",
POSTHOG_CLIENT_KEY: "test-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "oss",
posthog_client_key: "test-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});
@@ -167,20 +167,20 @@ describe("frontend/routes/_oh", () => {
it("should not render the user consent form if saas mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - partial mock for testing
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-id",
POSTHOG_CLIENT_KEY: "test-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
posthog_client_key: "test-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
@@ -255,20 +255,20 @@ describe("frontend/routes/_oh", () => {
"displaySuccessToast",
);
// @ts-expect-error - partial mock for testing
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-id",
POSTHOG_CLIENT_KEY: "test-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
posthog_client_key: "test-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});

View File

@@ -10,35 +10,49 @@ import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { WebClientConfig } from "#/api/option-service/option.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { SecretsService } from "#/api/secrets-service";
import { integrationService } from "#/api/integration-service/integration-service.api";
const VALID_OSS_CONFIG: GetConfigResponse = {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
const VALID_OSS_CONFIG: WebClientConfig = {
app_mode: "oss",
posthog_client_key: "456",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
};
const VALID_SAAS_CONFIG: GetConfigResponse = {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
const VALID_SAAS_CONFIG: WebClientConfig = {
app_mode: "saas",
posthog_client_key: "456",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
};
const queryClient = new QueryClient();
@@ -247,7 +261,7 @@ describe("Content", () => {
});
});
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
it("should render the 'Configure GitHub Repositories' button if SaaS mode and github_app_slug exists", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
@@ -272,7 +286,7 @@ describe("Content", () => {
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
github_app_slug: "test-slug",
});
queryClient.invalidateQueries();
rerender();
@@ -615,7 +629,6 @@ describe("GitLab Webhook Manager Integration", () => {
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
@@ -633,40 +646,4 @@ describe("GitLab Webhook Manager Integration", () => {
).not.toBeInTheDocument();
});
});
it("should render GitLab webhook manager when token is set", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getResourcesSpy = vi.spyOn(
integrationService,
"getGitLabResources",
);
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: null,
},
});
getResourcesSpy.mockResolvedValue({
resources: [],
});
// Act
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
// Assert
await waitFor(() => {
expect(
screen.getByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
).toBeInTheDocument();
expect(getResourcesSpy).toHaveBeenCalled();
});
});
});

View File

@@ -16,11 +16,11 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted(
() => {
const defaultFeatureFlags = {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
};
return {
@@ -33,8 +33,8 @@ const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted(
}),
useConfigMock: vi.fn().mockReturnValue({
data: {
APP_MODE: "oss",
FEATURE_FLAGS: defaultFeatureFlags,
app_mode: "oss",
feature_flags: defaultFeatureFlags,
},
isLoading: false,
}),
@@ -141,19 +141,24 @@ describe("HomeScreen", () => {
isError: false,
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
// Mock config to avoid SaaS redirect logic
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
app_mode: "oss",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: DEFAULT_FEATURE_FLAGS,
maintenance_start_time: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
});
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
@@ -444,18 +449,23 @@ describe("Settings 404", () => {
isError: false,
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
app_mode: "oss",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: DEFAULT_FEATURE_FLAGS,
maintenance_start_time: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
});
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
@@ -510,14 +520,14 @@ describe("Settings 404", () => {
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
// @ts-expect-error - we only need APP_MODE for this test
// @ts-expect-error - we only need app_mode for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
app_mode: "saas",
feature_flags: DEFAULT_FEATURE_FLAGS,
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
@@ -543,20 +553,25 @@ describe("Setup Payment modal", () => {
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
FEATURE_FLAGS: { ...DEFAULT_FEATURE_FLAGS, ENABLE_BILLING: true },
app_mode: "saas",
feature_flags: { ...DEFAULT_FEATURE_FLAGS, enable_billing: true },
},
isLoading: false,
});
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: { ...DEFAULT_FEATURE_FLAGS, ENABLE_BILLING: true },
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
app_mode: "saas",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: { ...DEFAULT_FEATURE_FLAGS, enable_billing: true },
maintenance_start_time: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
});
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);

View File

@@ -337,9 +337,9 @@ describe("Content", () => {
describe("API key visibility in Basic Settings", () => {
it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
// @ts-expect-error - only return app_mode for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
app_mode: "saas",
});
renderLlmSettingsScreen();
@@ -364,9 +364,9 @@ describe("Content", () => {
it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
// @ts-expect-error - only return app_mode for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
app_mode: "saas",
});
renderLlmSettingsScreen();
@@ -395,9 +395,9 @@ describe("Content", () => {
it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
// @ts-expect-error - only return app_mode for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
app_mode: "oss",
});
renderLlmSettingsScreen();
@@ -422,9 +422,9 @@ describe("Content", () => {
it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
// @ts-expect-error - only return app_mode for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
app_mode: "oss",
});
renderLlmSettingsScreen();
@@ -453,9 +453,9 @@ describe("Content", () => {
it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
// @ts-expect-error - only return app_mode for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
app_mode: "saas",
});
renderLlmSettingsScreen();
@@ -498,9 +498,9 @@ describe("Content", () => {
it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
// @ts-expect-error - only return app_mode for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
app_mode: "saas",
});
renderLlmSettingsScreen();
@@ -1010,16 +1010,16 @@ describe("View persistence after saving advanced settings", () => {
it("should remain on Advanced view after saving when search API key is set", async () => {
// Arrange: Start with default settings (non-SaaS mode to show search API key field)
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - partial mock for testing
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "oss",
posthog_client_key: "fake-posthog-client-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});

View File

@@ -87,18 +87,18 @@ describe("LoginPage", () => {
vi.clearAllMocks();
vi.stubGlobal("location", { href: "" });
// @ts-expect-error - partial mock for testing
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github", "gitlab", "bitbucket"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
posthog_client_key: "test-posthog-key",
providers_configured: ["github", "gitlab", "bitbucket"],
auth_url: "https://auth.example.com",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});
@@ -151,18 +151,18 @@ describe("LoginPage", () => {
});
it("should only display configured providers", async () => {
// @ts-expect-error - partial mock for testing
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});
@@ -187,18 +187,18 @@ describe("LoginPage", () => {
});
it("should display message when no providers are configured", async () => {
// @ts-expect-error - partial mock for testing
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: [],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
posthog_client_key: "test-posthog-key",
providers_configured: [],
auth_url: "https://auth.example.com",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});
@@ -320,16 +320,16 @@ describe("LoginPage", () => {
});
it("should redirect OSS mode users to home", async () => {
// @ts-expect-error - partial mock for testing
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "oss",
posthog_client_key: "test-posthog-key",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});

View File

@@ -21,11 +21,11 @@ vi.mock("#/hooks/query/use-config", () => ({
}));
const DEFAULT_FEATURE_FLAGS = {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
};
const RouterStub = createRoutesStub([
@@ -61,9 +61,9 @@ describe("MainApp - Auth refetch behavior", () => {
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
app_mode: "saas",
github_client_id: "test-client-id",
feature_flags: DEFAULT_FEATURE_FLAGS,
},
isLoading: false,
});

View File

@@ -160,18 +160,18 @@ describe("MainApp", () => {
beforeEach(() => {
vi.clearAllMocks();
// @ts-expect-error - partial mock for testing
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
});

View File

@@ -58,7 +58,7 @@ beforeEach(() => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
app_mode: "oss",
});
});
@@ -78,7 +78,7 @@ describe("Content", () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
app_mode: "oss",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
@@ -97,7 +97,7 @@ describe("Content", () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
app_mode: "saas",
});
renderSecretsSettings();

View File

@@ -64,15 +64,15 @@ describe("Settings Billing", () => {
// Set default config to OSS mode
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "oss",
github_client_id: "123",
posthog_client_key: "456",
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
},
isLoading: false,
@@ -119,15 +119,15 @@ describe("Settings Billing", () => {
it("should render the billing tab if SaaS mode and billing is enabled", async () => {
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
github_client_id: "123",
posthog_client_key: "456",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
},
isLoading: false,
@@ -143,15 +143,15 @@ describe("Settings Billing", () => {
const user = userEvent.setup();
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
app_mode: "saas",
github_client_id: "123",
posthog_client_key: "456",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
},
isLoading: false,

View File

@@ -96,7 +96,7 @@ describe("Settings Screen", () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
app_mode: "oss",
});
// Clear any existing query data
@@ -122,11 +122,11 @@ describe("Settings Screen", () => {
});
it("should render the saas navbar", async () => {
const saasConfig = { APP_MODE: "saas" };
const saasConfig = { app_mode: "saas" };
// Clear any existing query data and set the config
mockQueryClient.clear();
mockQueryClient.setQueryData(["config"], saasConfig);
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
const sectionsToInclude = [
"llm", // LLM settings are now always shown in SaaS mode
@@ -160,7 +160,7 @@ describe("Settings Screen", () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
app_mode: "oss",
});
// Clear any existing query data

View File

@@ -1,6 +1,6 @@
import { openHands } from "../open-hands-axios";
import { AuthenticateResponse, GitHubAccessTokenResponse } from "./auth.types";
import { GetConfigResponse } from "../option-service/option.types";
import { WebClientConfig } from "../option-service/option.types";
/**
* Authentication service for handling all authentication-related API calls
@@ -12,7 +12,7 @@ class AuthService {
* @returns Response with authentication status and user info if successful
*/
static async authenticate(
appMode: GetConfigResponse["APP_MODE"],
appMode: WebClientConfig["app_mode"],
): Promise<boolean> {
if (appMode === "oss") return true;
@@ -42,7 +42,7 @@ class AuthService {
* Logout user from the application
* @param appMode The application mode (saas or oss)
*/
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
static async logout(appMode: WebClientConfig["app_mode"]): Promise<void> {
const endpoint =
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
await openHands.post(endpoint);

View File

@@ -319,12 +319,12 @@ class V1ConversationService {
/**
* Read a file from a specific conversation's sandbox workspace
* @param conversationId The conversation ID
* @param filePath Path to the file to read within the sandbox workspace (defaults to /workspace/project/PLAN.md)
* @param filePath Path to the file to read within the sandbox workspace (defaults to /workspace/project/.agents_tmp/PLAN.md)
* @returns The content of the file or an empty string if the file doesn't exist
*/
static async readConversationFile(
conversationId: string,
filePath: string = "/workspace/project/PLAN.md",
filePath: string = "/workspace/project/.agents_tmp/PLAN.md",
): Promise<string> {
const params = new URLSearchParams();
params.append("file_path", filePath);

View File

@@ -1,5 +1,5 @@
import { openHands } from "../open-hands-axios";
import { GetConfigResponse } from "./option.types";
import { WebClientConfig } from "./option.types";
/**
* Service for handling API options endpoints
@@ -35,12 +35,12 @@ class OptionService {
}
/**
* Get the configuration from the server
* @returns Configuration response
* Get the web client configuration from the server
* @returns Web client configuration response
*/
static async getConfig(): Promise<GetConfigResponse> {
const { data } = await openHands.get<GetConfigResponse>(
"/api/options/config",
static async getConfig(): Promise<WebClientConfig> {
const { data } = await openHands.get<WebClientConfig>(
"/api/v1/web-client/config",
);
return data;
}

View File

@@ -1,21 +1,23 @@
import { Provider } from "#/types/settings";
export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
APP_SLUG?: string;
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
RECAPTCHA_SITE_KEY?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;
};
MAINTENANCE?: {
startTime: string;
};
export interface WebClientFeatureFlags {
enable_billing: boolean;
hide_llm_settings: boolean;
enable_jira: boolean;
enable_jira_dc: boolean;
enable_linear: boolean;
}
export interface WebClientConfig {
app_mode: "saas" | "oss";
posthog_client_key: string | null;
feature_flags: WebClientFeatureFlags;
providers_configured: Provider[];
maintenance_start_time: string | null;
auth_url: string | null;
recaptcha_site_key: string | null;
faulty_models: string[];
error_message: string | null;
updated_at: string;
github_app_slug: string | null;
}

View File

@@ -0,0 +1,143 @@
import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { useLocalStorage } from "@uidotdev/usehooks";
import { FaTriangleExclamation } from "react-icons/fa6";
import CloseIcon from "#/icons/close.svg?react";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface AlertBannerProps {
maintenanceStartTime?: string | null;
faultyModels?: string[];
errorMessage?: string | null;
updatedAt: string;
}
export function AlertBanner({
maintenanceStartTime,
faultyModels,
errorMessage,
updatedAt,
}: AlertBannerProps) {
const { t } = useTranslation();
const [dismissedAt, setDismissedAt] = useLocalStorage<string | null>(
"alert_banner_dismissed_at",
null,
);
const { pathname } = useLocation();
// Format ISO timestamp to user's local timezone
const formatMaintenanceTime = (isoTimeString: string): string => {
const date = new Date(isoTimeString);
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
};
const localTime = maintenanceStartTime
? formatMaintenanceTime(maintenanceStartTime)
: null;
const hasMaintenanceAlert = !!maintenanceStartTime;
const hasFaultyModels = faultyModels && faultyModels.length > 0;
const hasErrorMessage = errorMessage && errorMessage.trim().length > 0;
const hasAnyAlert = hasMaintenanceAlert || hasFaultyModels || hasErrorMessage;
const isBannerVisible = useMemo(() => {
if (!hasAnyAlert) {
return false;
}
return dismissedAt !== updatedAt;
}, [dismissedAt, updatedAt, hasAnyAlert]);
// Try to translate error message, fallback to raw message
const translatedErrorMessage = useMemo(() => {
if (!errorMessage) return null;
// Check if the error message is a translation key (e.g., "ERROR$SOME_KEY")
const translated = t(errorMessage as I18nKey);
// If translation returns the same key, it means no translation exists
if (translated === errorMessage) {
return errorMessage;
}
return translated;
}, [errorMessage, t]);
if (!isBannerVisible) {
return null;
}
const renderMessages = () => {
const messages: React.ReactNode[] = [];
if (hasMaintenanceAlert && localTime) {
messages.push(
<Typography.Paragraph key="maintenance" className="text-sm font-medium">
{t(I18nKey.MAINTENANCE$SCHEDULED_MESSAGE, { time: localTime })}
</Typography.Paragraph>,
);
}
if (hasFaultyModels) {
messages.push(
<Typography.Paragraph
key="faulty-models"
className="text-sm font-medium"
>
{t(I18nKey.ALERT$FAULTY_MODELS_MESSAGE)} {faultyModels!.join(", ")}
</Typography.Paragraph>,
);
}
if (hasErrorMessage && translatedErrorMessage) {
messages.push(
<Typography.Paragraph
key="error-message"
className="text-sm font-medium"
>
{translatedErrorMessage}
</Typography.Paragraph>,
);
}
return messages;
};
return (
<div
data-testid="alert-banner"
className={cn(
"bg-[#0D0F11] border border-primary text-white p-4 rounded",
"flex flex-row items-center justify-between m-1",
pathname === "/" && "mt-3 mr-3",
)}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<FaTriangleExclamation className="text-primary align-middle" />
</div>
<div className="ml-3 flex flex-col gap-1">{renderMessages()}</div>
</div>
<button
type="button"
data-testid="dismiss-button"
onClick={() => setDismissedAt(updatedAt)}
className={cn(
"bg-[#0D0F11] rounded-full w-5 h-5 flex items-center justify-center cursor-pointer",
)}
>
<CloseIcon />
</button>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
import { useAuthUrl } from "#/hooks/use-auth-url";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { WebClientConfig } from "#/api/option-service/option.types";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
@@ -15,8 +15,8 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers";
export interface LoginContentProps {
githubAuthUrl: string | null;
appMode?: GetConfigResponse["APP_MODE"] | null;
authUrl?: GetConfigResponse["AUTH_URL"];
appMode?: WebClientConfig["app_mode"] | null;
authUrl?: WebClientConfig["auth_url"];
providersConfigured?: Provider[];
emailVerified?: boolean;
hasDuplicatedEmail?: boolean;
@@ -38,7 +38,7 @@ export function LoginContent({
// reCAPTCHA - only need token generation, verification happens at backend callback
const { isReady: recaptchaReady, executeRecaptcha } = useRecaptcha({
siteKey: config?.RECAPTCHA_SITE_KEY,
siteKey: config?.recaptcha_site_key ?? undefined,
});
const gitlabAuthUrl = useAuthUrl({
@@ -59,7 +59,7 @@ export function LoginContent({
) => {
trackLoginButtonClick({ provider });
if (!config?.RECAPTCHA_SITE_KEY || !recaptchaReady) {
if (!config?.recaptcha_site_key || !recaptchaReady) {
// No reCAPTCHA or token generation failed - redirect normally
window.location.href = redirectUrl;
return;

View File

@@ -7,7 +7,7 @@ import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { AgentState } from "#/types/agent-state";
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
import { useFilteredEvents } from "#/hooks/use-filtered-events";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
@@ -27,28 +27,13 @@ import { ChatMessagesSkeleton } from "./chat-messages-skeleton";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useEventStore } from "#/stores/use-event-store";
import { ErrorMessageBanner } from "./error-message-banner";
import {
hasUserEvent,
shouldRenderEvent,
} from "./event-content-helpers/should-render-event";
import {
Messages as V1Messages,
hasUserEvent as hasV1UserEvent,
shouldRenderEvent as shouldRenderV1Event,
} from "#/components/v1/chat";
import { Messages as V1Messages } from "#/components/v1/chat";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/stores/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
import {
isV0Event,
isV1Event,
isSystemPromptEvent,
isConversationStateUpdateEvent,
} from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
@@ -73,8 +58,16 @@ export function ChatInterface() {
const { isTask, taskStatus, taskDetail } = useTaskPolling();
const conversationWebSocket = useConversationWebSocket();
const { send } = useSendMessage();
const storeEvents = useEventStore((state) => state.events);
const uiEvents = useEventStore((state) => state.uiEvents);
const {
v0Events,
v1UiEvents,
v1FullEvents,
totalEvents,
hasSubstantiveAgentActions,
v0UserEventsExist,
v1UserEventsExist,
userEventsExist,
} = useFilteredEvents();
const { setOptimisticUserMessage, getOptimisticUserMessage } =
useOptimisticUserMessageStore();
const { t } = useTranslation();
@@ -140,74 +133,21 @@ export function ChatInterface() {
const isV1Conversation = conversation?.conversation_version === "V1";
// Track when we should show V1 messages (after DOM has rendered)
const [showV1Messages, setShowV1Messages] = React.useState(false);
const prevV1LoadingRef = React.useRef(
conversationWebSocket?.isLoadingHistory,
);
// Wait for DOM to render before showing V1 messages
React.useEffect(() => {
const wasLoading = prevV1LoadingRef.current;
const isLoading = conversationWebSocket?.isLoadingHistory;
if (wasLoading && !isLoading) {
// Loading just finished - wait for next frame to ensure DOM is ready
requestAnimationFrame(() => {
setShowV1Messages(true);
});
} else if (isLoading) {
// Reset when loading starts
setShowV1Messages(false);
}
prevV1LoadingRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory]);
// Show V1 messages immediately if events exist in store (e.g., remount),
// or once loading completes. This replaces the old transition-observation
// pattern (useState + useEffect watching loading→loaded) which always showed
// skeleton on remount because local state initialized to false.
const showV1Messages =
v1FullEvents.length > 0 || !conversationWebSocket?.isLoadingHistory;
const isReturningToConversation = !!params.conversationId;
// Only show loading skeleton when genuinely loading AND no events in store yet.
// If events exist (e.g., remount after data was already fetched), skip skeleton.
const isHistoryLoading =
(isLoadingMessages && !isV1Conversation) ||
(isV1Conversation &&
(conversationWebSocket?.isLoadingHistory || !showV1Messages));
(isLoadingMessages && !isV1Conversation && v0Events.length === 0) ||
(isV1Conversation && !showV1Messages);
const isChatLoading = isHistoryLoading && !isTask;
// Filter V0 events
const v0Events = storeEvents
.filter(isV0Event)
.filter(isActionOrObservation)
.filter(shouldRenderEvent);
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
const v1UiEvents = uiEvents.filter(isV1Event).filter(shouldRenderV1Event);
// Keep full v1 events for lookups (includes both actions and observations)
const v1FullEvents = storeEvents.filter(isV1Event);
// Combined events count for tracking
const totalEvents = v0Events.length || v1UiEvents.length;
// Check if there are any substantive agent actions (not just system messages)
const hasSubstantiveAgentActions = React.useMemo(
() =>
storeEvents
.filter(isV0Event)
.filter(isActionOrObservation)
.some(
(event) =>
isOpenHandsAction(event) &&
event.source === "agent" &&
event.action !== "system",
) ||
storeEvents
.filter(isV1Event)
.some(
(event) =>
event.source === "agent" &&
!isSystemPromptEvent(event) &&
!isConversationStateUpdateEvent(event),
),
[storeEvents],
);
const handleSendMessage = async (
content: string,
originalImages: File[],
@@ -269,6 +209,21 @@ export function ChatInterface() {
setFeedbackPolarity(polarity);
};
// Auto-scroll to bottom when new messages arrive
React.useEffect(() => {
if (autoScroll) {
scrollDomToBottom();
}
// Note: We intentionally exclude autoScroll from deps because we only want
// to scroll when message content changes, not when autoScroll state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
v1UiEvents.length,
v0Events.length,
optimisticUserMessage,
scrollDomToBottom,
]);
// Create a ScrollProvider with the scroll hook values
const scrollProviderValue = {
scrollRef,
@@ -280,10 +235,6 @@ export function ChatInterface() {
onChatBodyScroll,
};
const v0UserEventsExist = hasUserEvent(v0Events);
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
// Get server status indicator props
const isStartingStatus =
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
@@ -337,7 +288,7 @@ export function ChatInterface() {
</div>
)}
{!isLoadingMessages && v0UserEventsExist && (
{(!isLoadingMessages || v0Events.length > 0) && v0UserEventsExist && (
<V0Messages
messages={v0Events}
isAwaitingUserConfirmation={
@@ -369,7 +320,7 @@ export function ChatInterface() {
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
isSaasMode={config?.APP_MODE === "saas"}
isSaasMode={config?.app_mode === "saas"}
/>
)}
</div>
@@ -391,7 +342,7 @@ export function ChatInterface() {
<InteractiveChatBox onSubmit={handleSendMessage} />
</div>
{config?.APP_MODE !== "saas" && !isV1Conversation && (
{config?.app_mode !== "saas" && !isV1Conversation && (
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}

View File

@@ -55,7 +55,9 @@ export function ChatMessage({
"flex flex-col gap-2",
type === "user" && "p-4 bg-tertiary self-end",
type === "agent" && "mt-6 w-full max-w-full bg-transparent",
isFromPlanningAgent && "border border-[#597ff4] bg-tertiary p-4 mt-2",
isFromPlanningAgent &&
type === "agent" &&
"border border-[#597ff4] bg-tertiary p-4 mt-2",
)}
>
<div

View File

@@ -18,7 +18,7 @@ interface ErrorEventMessageProps {
}>;
isLastMessage: boolean;
isInLast10Actions: boolean;
config?: { APP_MODE?: string } | null;
config?: { app_mode?: string } | null;
isCheckingFeedback: boolean;
feedbackData: {
exists: boolean;

View File

@@ -19,7 +19,7 @@ interface FinishEventMessageProps {
}>;
isLastMessage: boolean;
isInLast10Actions: boolean;
config?: { APP_MODE?: string } | null;
config?: { app_mode?: string } | null;
isCheckingFeedback: boolean;
feedbackData: {
exists: boolean;

View File

@@ -8,7 +8,7 @@ interface LikertScaleWrapperProps {
event: OpenHandsAction | OpenHandsObservation;
isLastMessage: boolean;
isInLast10Actions: boolean;
config?: { APP_MODE?: string } | null;
config?: { app_mode?: string } | null;
isCheckingFeedback: boolean;
feedbackData: {
exists: boolean;
@@ -25,7 +25,7 @@ export function LikertScaleWrapper({
isCheckingFeedback,
feedbackData,
}: LikertScaleWrapperProps) {
if (config?.APP_MODE !== "saas" || isCheckingFeedback) {
if (config?.app_mode !== "saas" || isCheckingFeedback) {
return null;
}

View File

@@ -23,7 +23,7 @@ interface UserAssistantEventMessageProps {
}>;
isLastMessage: boolean;
isInLast10Actions: boolean;
config?: { APP_MODE?: string } | null;
config?: { app_mode?: string } | null;
isCheckingFeedback: boolean;
feedbackData: {
exists: boolean;

View File

@@ -94,8 +94,8 @@ export function ExpandableMessage({
const statusIconClasses = "h-4 w-4 ml-2 inline";
if (
config?.FEATURE_FLAGS?.ENABLE_BILLING &&
config?.APP_MODE === "saas" &&
config?.feature_flags?.enable_billing &&
config?.app_mode === "saas" &&
id === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS
) {
return (

View File

@@ -39,14 +39,14 @@ export function PlanPreview({
isBuildDisabled,
}: PlanPreviewProps) {
const { t } = useTranslation();
const { selectTab } = useSelectConversationTab();
const { navigateToTab } = useSelectConversationTab();
const { handleBuildPlanClick } = useHandleBuildPlanClick();
const { scrollDomToBottom } = useScrollContext();
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const handleViewClick = () => {
selectTab("planner");
navigateToTab("planner");
};
// Handle Build action with scroll to bottom
@@ -65,7 +65,7 @@ export function PlanPreview({
return `${planContent.slice(0, MAX_CONTENT_LENGTH)}...`;
}, [planContent]);
if (!shouldUsePlanningAgent) {
if (!shouldUsePlanningAgent || !planContent) {
return null;
}

View File

@@ -1,12 +1,22 @@
import { LoaderCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
export function ConversationLoading() {
type ConversationLoadingProps = {
className?: string;
};
export function ConversationLoading({ className }: ConversationLoadingProps) {
const { t } = useTranslation();
return (
<div className="bg-[#25272D] flex flex-col items-center justify-center h-full w-full">
<div
className={cn(
"bg-[#25272D] flex flex-col items-center justify-center h-full w-full",
className,
)}
>
<LoaderCircle className="animate-spin w-16 h-16" color="white" />
<span className="text-2xl font-normal leading-5 text-white p-4">
{t(I18nKey.HOME$LOADING)}

View File

@@ -1,15 +1,113 @@
import { useWindowSize } from "@uidotdev/usehooks";
import { MobileLayout } from "./mobile-layout";
import { DesktopLayout } from "./desktop-layout";
import { cn } from "#/utils/utils";
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
import { ResizeHandle } from "../../../ui/resize-handle";
import { useResizablePanels } from "#/hooks/use-resizable-panels";
import { useConversationStore } from "#/stores/conversation-store";
import { useBreakpoint } from "#/hooks/use-breakpoint";
function getMobileChatPanelClass(isRightPanelShown: boolean) {
return isRightPanelShown ? "h-160" : "flex-1";
}
function getDesktopTabPanelClass(isRightPanelShown: boolean) {
return isRightPanelShown
? "translate-x-0 opacity-100"
: "w-0 translate-x-full opacity-0";
}
export function ConversationMain() {
const { width } = useWindowSize();
const isMobile = useBreakpoint();
const { isRightPanelShown } = useConversationStore();
if (width && width <= 1024) {
return <MobileLayout isRightPanelShown={isRightPanelShown} />;
}
const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =
useResizablePanels({
defaultLeftWidth: 50,
minLeftWidth: 30,
maxLeftWidth: 80,
storageKey: "desktop-layout-panel-width",
});
return <DesktopLayout isRightPanelShown={isRightPanelShown} />;
return (
<div
className={cn(
isMobile
? "relative flex-1 flex flex-col"
: "h-full flex flex-col overflow-hidden",
)}
>
<div
ref={containerRef}
className={cn(
"flex flex-1 overflow-hidden",
isMobile ? "flex-col" : "transition-all duration-300 ease-in-out",
)}
style={
!isMobile
? { transitionProperty: isDragging ? "none" : "all" }
: undefined
}
>
{/* Chat Panel - always mounted, styled differently for mobile/desktop */}
<div
className={cn(
"flex flex-col bg-base overflow-hidden",
isMobile
? getMobileChatPanelClass(isRightPanelShown)
: "transition-all duration-300 ease-in-out",
)}
style={
!isMobile
? {
width: isRightPanelShown ? `${leftWidth}%` : "100%",
transitionProperty: isDragging ? "none" : "all",
}
: undefined
}
>
<ChatInterfaceWrapper
isRightPanelShown={!isMobile && isRightPanelShown}
/>
</div>
{/* Resize Handle - only shown on desktop when right panel is visible */}
{!isMobile && isRightPanelShown && (
<ResizeHandle onMouseDown={handleMouseDown} />
)}
{/* Tab Content Panel - always mounted, styled as bottom sheet (mobile) or side panel (desktop) */}
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isMobile
? cn(
"absolute bottom-4 left-0 right-0 top-160",
isRightPanelShown
? "h-160 translate-y-0 opacity-100"
: "h-0 translate-y-full opacity-0",
)
: getDesktopTabPanelClass(isRightPanelShown),
)}
style={
!isMobile
? {
width: isRightPanelShown ? `${rightWidth}%` : "0%",
transitionProperty: isDragging ? "opacity, transform" : "all",
}
: undefined
}
>
<div
className={cn(
isMobile
? "h-full flex flex-col gap-3 pb-2 md:pb-0 pt-2"
: "flex flex-col flex-1 gap-3 min-w-max h-full",
)}
>
<ConversationTabContent />
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,64 +0,0 @@
import { cn } from "#/utils/utils";
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
import { ResizeHandle } from "../../../ui/resize-handle";
import { useResizablePanels } from "#/hooks/use-resizable-panels";
interface DesktopLayoutProps {
isRightPanelShown: boolean;
}
export function DesktopLayout({ isRightPanelShown }: DesktopLayoutProps) {
const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =
useResizablePanels({
defaultLeftWidth: 50,
minLeftWidth: 30,
maxLeftWidth: 80,
storageKey: "desktop-layout-panel-width",
});
return (
<div className="h-full flex flex-col overflow-hidden">
<div
ref={containerRef}
className="flex flex-1 transition-all duration-300 ease-in-out overflow-hidden"
style={{
// Only apply smooth transitions when not dragging
transitionProperty: isDragging ? "none" : "all",
}}
>
{/* Left Panel (Chat) */}
<div
className="flex flex-col bg-base overflow-hidden transition-all duration-300 ease-in-out"
style={{
width: isRightPanelShown ? `${leftWidth}%` : "100%",
transitionProperty: isDragging ? "none" : "all",
}}
>
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
</div>
{/* Resize Handle */}
{isRightPanelShown && <ResizeHandle onMouseDown={handleMouseDown} />}
{/* Right Panel */}
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isRightPanelShown
? "translate-x-0 opacity-100"
: "w-0 translate-x-full opacity-0",
)}
style={{
width: isRightPanelShown ? `${rightWidth}%` : "0%",
transitionProperty: isDragging ? "opacity, transform" : "all",
}}
>
<div className="flex flex-col flex-1 gap-3 min-w-max h-full">
<ConversationTabContent />
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
import { ChatInterface } from "../../chat/chat-interface";
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
import { cn } from "#/utils/utils";
interface MobileLayoutProps {
isRightPanelShown: boolean;
}
export function MobileLayout({ isRightPanelShown }: MobileLayoutProps) {
return (
<div className="relative flex-1 flex flex-col">
{/* Chat area - shrinks when panel slides up */}
<div
className={cn(
"bg-base overflow-hidden",
isRightPanelShown ? "h-160" : "flex-1",
)}
>
<ChatInterface />
</div>
{/* Bottom panel - slides up from bottom */}
<div
className={cn(
"absolute bottom-4 left-0 right-0 top-160 transition-all duration-300 ease-in-out overflow-hidden",
isRightPanelShown
? "h-160 translate-y-0 opacity-100"
: "h-0 translate-y-full opacity-0",
)}
>
<div className="h-full flex flex-col gap-3 pb-2 md:pb-0 pt-2">
<ConversationTabContent />
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { useWindowSize } from "@uidotdev/usehooks";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { useBreakpoint } from "#/hooks/use-breakpoint";
import { cn } from "#/utils/utils";
import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
@@ -60,7 +60,7 @@ export function ConversationNameContextMenu({
shareUrl,
position = "bottom",
}: ConversationNameContextMenuProps) {
const { width } = useWindowSize();
const isMobile = useBreakpoint();
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
@@ -73,7 +73,7 @@ export function ConversationNameContextMenu({
// Check if we should show the public sharing option
// Only show for V1 conversations in SAAS mode
const shouldShowPublicSharing =
isV1Conversation && config?.APP_MODE === "saas" && onTogglePublic;
isV1Conversation && config?.app_mode === "saas" && onTogglePublic;
const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation);
const hasExport = Boolean(onExportConversation);
@@ -81,8 +81,6 @@ export function ConversationNameContextMenu({
const hasInfo = Boolean(onDisplayCost);
const hasControl = Boolean(onStop || onDelete);
const isMobile = width && width <= 1024;
return (
<ContextMenu
ref={ref}

View File

@@ -58,7 +58,7 @@ export function ConversationTabContent() {
const conversationTabTitle = t(activeTab.titleKey);
if (shouldShownAgentLoading) {
return <ConversationLoading />;
return <ConversationLoading className="rounded-xl" />;
}
return (

View File

@@ -247,34 +247,6 @@ export function GitRepoDropdown({
const isLoadingState =
isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading;
// Create sticky footer item for GitHub provider
const stickyFooterItem = useMemo(() => {
if (
!config?.APP_SLUG ||
provider !== ProviderOptions.github ||
config.APP_MODE !== "saas"
)
return null;
const githubHref = `https://github.com/apps/${config.APP_SLUG}/installations/new`;
return (
<a
href={githubHref}
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full px-2 py-2 text-sm text-white hover:bg-[#5C5D62] rounded-md transition-colors duration-150 font-normal"
onMouseDown={(e) => {
// Prevent downshift from closing the menu when clicking the sticky footer
e.preventDefault();
e.stopPropagation();
}}
>
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
</a>
);
}, [provider, config, t]);
const renderItem = (
item: GitRepository,
index: number,
@@ -317,6 +289,34 @@ export function GitRepoDropdown({
);
}, [recentRepositories, localSelectedItem, getItemProps, t]);
// Create sticky footer item for GitHub provider
const stickyFooterItem = useMemo(() => {
if (
!config?.github_app_slug ||
provider !== ProviderOptions.github ||
config.app_mode !== "saas"
)
return null;
const githubHref = `https://github.com/apps/${config.github_app_slug}/installations/new`;
return (
<a
href={githubHref}
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full px-2 py-2 text-sm text-white hover:bg-[#5C5D62] rounded-md transition-colors duration-150 font-normal"
onMouseDown={(e) => {
// Prevent downshift from closing the menu when clicking the sticky footer
e.preventDefault();
e.stopPropagation();
}}
>
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
</a>
);
}, [provider, config, t]);
return (
<div className={cn("relative", className)}>
<div className="relative">

View File

@@ -1,111 +0,0 @@
import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { useLocalStorage } from "@uidotdev/usehooks";
import { FaTriangleExclamation } from "react-icons/fa6";
import CloseIcon from "#/icons/close.svg?react";
import { cn } from "#/utils/utils";
interface MaintenanceBannerProps {
startTime: string;
}
export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) {
const { t } = useTranslation();
const [dismissedAt, setDismissedAt] = useLocalStorage<string | null>(
"maintenance_banner_dismissed_at",
null,
);
const { pathname } = useLocation();
// Convert EST timestamp to user's local timezone
const formatMaintenanceTime = (estTimeString: string): string => {
try {
// Parse the EST timestamp
// If the string doesn't include timezone info, assume it's EST
let dateToFormat: Date;
if (
estTimeString.includes("T") &&
(estTimeString.includes("-05:00") ||
estTimeString.includes("-04:00") ||
estTimeString.includes("EST") ||
estTimeString.includes("EDT"))
) {
// Already has timezone info
dateToFormat = new Date(estTimeString);
} else {
// Assume EST and convert to UTC for proper parsing
// EST is UTC-5, EDT is UTC-4, but we'll assume EST for simplicity
const estDate = new Date(estTimeString);
if (Number.isNaN(estDate.getTime())) {
throw new Error("Invalid date");
}
dateToFormat = estDate;
}
// Format to user's local timezone
return dateToFormat.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
} catch (error) {
// Fallback to original string if parsing fails
// eslint-disable-next-line no-console
console.warn("Failed to parse maintenance time:", error);
return estTimeString;
}
};
const localTime = formatMaintenanceTime(startTime);
const isBannerVisible = useMemo(() => {
const isValid = !Number.isNaN(new Date(startTime).getTime());
if (!isValid) {
return false;
}
return dismissedAt !== localTime;
}, [dismissedAt, startTime]);
if (!isBannerVisible) {
return null;
}
return (
<div
data-testid="maintenance-banner"
className={cn(
"bg-primary text-[#0D0F11] p-4 rounded",
"flex flex-row items-center justify-between m-1",
pathname === "/" && "mt-3 mr-3",
)}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<FaTriangleExclamation className="text-white align-middle" />
</div>
<div className="ml-3">
<p className="text-sm font-medium">
{t("MAINTENANCE$SCHEDULED_MESSAGE", { time: localTime })}
</p>
</div>
</div>
<button
type="button"
data-testid="dismiss-button"
onClick={() => setDismissedAt(localTime)}
className={cn(
"bg-[#0D0F11] rounded-full w-5 h-5 flex items-center justify-center cursor-pointer",
)}
>
<CloseIcon />
</button>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
import { MicroagentManagementMain } from "./microagent-management-main";
@@ -27,6 +27,7 @@ import {
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useBreakpoint } from "#/hooks/use-breakpoint";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
@@ -92,8 +93,7 @@ const getUpdateConversationInstructions = (
`;
export function MicroagentManagementContent() {
// Responsive width state
const [width, setWidth] = useState(window.innerWidth);
const isMobile = useBreakpoint();
const {
addMicroagentModalVisible,
@@ -112,17 +112,6 @@ export function MicroagentManagementContent() {
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
function handleResize() {
setWidth(window.innerWidth);
}
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const hideUpsertMicroagentModal = (isUpdate: boolean = false) => {
if (isUpdate) {
setUpdateMicroagentModalVisible(false);
@@ -318,7 +307,7 @@ export function MicroagentManagementContent() {
const providersAreSet = providers.length > 0;
if (width < 1024) {
if (isMobile) {
return (
<div className="w-full h-full flex flex-col gap-6">
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useNavigate } from "react-router";
import { FaTrash, FaEye, FaEyeSlash, FaCopy } from "react-icons/fa6";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
@@ -19,12 +20,41 @@ import { useRefreshLlmApiKey } from "#/hooks/mutation/use-refresh-llm-api-key";
interface LlmApiKeyManagerProps {
llmApiKey: { key: string | null } | undefined;
isLoadingLlmKey: boolean;
isPaymentRequired: boolean;
refreshLlmApiKey: ReturnType<typeof useRefreshLlmApiKey>;
}
function LlmApiKeyPaywall() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="border-b border-gray-200 pb-6 mb-6 flex flex-col gap-6">
<h3 className="text-xl font-medium text-white">
{t(I18nKey.SETTINGS$LLM_API_KEY)}
</h3>
<div className="bg-base-tertiary rounded-md p-4 flex flex-col gap-4">
<p className="text-sm text-gray-300">
{t(I18nKey.SETTINGS$LLM_API_KEY_PAYWALL_MESSAGE)}
</p>
<div>
<BrandButton
type="button"
variant="primary"
onClick={() => navigate("/settings/billing")}
>
{t(I18nKey.SETTINGS$LLM_API_KEY_BUY_NOW)}
</BrandButton>
</div>
</div>
</div>
);
}
function LlmApiKeyManager({
llmApiKey,
isLoadingLlmKey,
isPaymentRequired,
refreshLlmApiKey,
}: LlmApiKeyManagerProps) {
const { t } = useTranslation();
@@ -45,6 +75,11 @@ function LlmApiKeyManager({
});
};
// Show paywall if payment is required
if (isPaymentRequired) {
return <LlmApiKeyPaywall />;
}
if (isLoadingLlmKey || !llmApiKey) {
return null;
}
@@ -206,7 +241,11 @@ function ApiKeysTable({ apiKeys, isLoading, onDeleteKey }: ApiKeysTableProps) {
export function ApiKeysManager() {
const { t } = useTranslation();
const { data: apiKeys = [], isLoading, error } = useApiKeys();
const { data: llmApiKey, isLoading: isLoadingLlmKey } = useLlmApiKey();
const {
data: llmApiKey,
isLoading: isLoadingLlmKey,
isPaymentRequired,
} = useLlmApiKey();
const refreshLlmApiKey = useRefreshLlmApiKey();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
@@ -215,8 +254,8 @@ export function ApiKeysManager() {
useState<CreateApiKeyResponse | null>(null);
const [showNewKeyModal, setShowNewKeyModal] = useState(false);
// Display error toast if the query fails
if (error) {
// Display error toast if the query fails (but not for payment required)
if (error && !isPaymentRequired) {
displayErrorToast(t(I18nKey.ERROR$GENERIC));
}
@@ -251,6 +290,7 @@ export function ApiKeysManager() {
<LlmApiKeyManager
llmApiKey={llmApiKey}
isLoadingLlmKey={isLoadingLlmKey}
isPaymentRequired={isPaymentRequired}
refreshLlmApiKey={refreshLlmApiKey}
/>

View File

@@ -9,9 +9,9 @@ export function ConfigureAzureDevOpsAnchor() {
const { data: config } = useConfig();
const authUrl = useAuthUrl({
appMode: config?.APP_MODE ?? null,
appMode: config?.app_mode ?? null,
identityProvider: "azure_devops",
authUrl: config?.AUTH_URL,
authUrl: config?.auth_url,
});
const handleOAuthFlow = () => {

View File

@@ -14,21 +14,21 @@ export function ProjectManagementIntegration() {
{t(I18nKey.PROJECT_MANAGEMENT$TITLE)}
</h3>
<div className="flex flex-col gap-4">
{config?.FEATURE_FLAGS?.ENABLE_JIRA && (
{config?.feature_flags?.enable_jira && (
<IntegrationRow
platform="jira"
platformName="Jira Cloud"
data-testid="jira-integration-row"
/>
)}
{config?.FEATURE_FLAGS?.ENABLE_JIRA_DC && (
{config?.feature_flags?.enable_jira_dc && (
<IntegrationRow
platform="jira-dc"
platformName="Jira Data Center"
data-testid="jira-dc-integration-row"
/>
)}
{config?.FEATURE_FLAGS?.ENABLE_LINEAR && (
{config?.feature_flags?.enable_linear && (
<IntegrationRow
platform="linear"
platformName="Linear"

Some files were not shown because too many files have changed in this diff Show More